Table des matières

Matériel utilisé

1. Raspberry Pi

2. Feather ESP8266 Huzzah

3. Module relais

4. DHT11 - Humidité

5. AM2315 - Température et humidité

6. DS18B20 - Température

7. BME280 et BMP280 - Pression, humidité, température

8. TSL2561 - Luminosité

9. ADS1115 - Lecture analogique

10. TMP36 - Température

11. Photorésistance - luminosité

12. PIR - Détection de mouvement

13. Contact magnétique

14. Senseur à effet Hall numérique

Code source

1. Téléchargement

2. GitHub

Configuration

1. Installation du Raspberry Pi

2. Utilitaires : des outils pour travailler

a. Connexion SSH

b. Éditeur de texte Nano

c. Transfert de fichiers via SSH (sftp)

d. Système de fichiers SSH

e. Bureau à distance

Type de données collectées

Présentation et concepts

1. Le broker MQTT, élément central du réseau MQTT

2. Les éléments de MQTT

3. Le broker MQTT

4. Les topics

5. Les publishers

6. Les subscribers

7. Le ClientId

Les topics en détail

1. Contenu du message

a. Le message selon MQTT

b. En marge du standard

2. Création de topic et bonnes pratiques

3. Les topics système

Topics du projet

QoS du projet

Sécurité

Configurer le login du broker MQTT

1. Modifier la configuration

2. Tester la configuration

MQTT en Python

1. test-mqtt-client-sub.py

2. test-mqtt-client-pub.py

3. Documentation complémentaire

MQTT en MicroPython

Présentation de l’ESP8266

1. Les possibilités offertes par l’ESP8266

2. Les plateformes ESP8266 populaires

3. Programmer un ESP8266

4. Feather Huzzah ESP8266 en détail

5. Brochage du Feather Huzzah ESP8266

a. Alimentation

b. Port série

c. Broches d’entrée/sortie

d. Entrée analogique

e. Les autres broches

Charger le firmware MicroPython

1. Identifier le firmware MicroPython

2. Préparatifs

3. Reflasher l’ESP8266

Prise de contrôle

1. Communiquer avec MicroPython

2. Communiquer avec un ESP8266 sous MicroPython

3. REPL : l’invite de commandes MicroPython

4. RShell

5. Ampy

WebREPL

1. Le démon WebREPL

a. Activer WebREPL sur l’ESP8266

b. Le mot de passe WebREPL

2. Client WebREPL

Nom d’hôte et adresse MAC

Le mode point d’accès (AP)

Le mode station (STA)

1. Mode STA et scan réseau

2. Réseau Wi-Fi visible ou masqué

3. Connexion en mode STA

4. WebREPL en mode STA

5. Désactivation du point d’accès

6. Rechercher l’adresse IP d’un ESP8266

Séquence de démarrage MicroPython

1. Fichier boot.py

2. Fichier main.py

3. Un fichier boot.py pour ESP8266

a. Script trop optimiste et conséquences

b. RunApp - Activation de l’application

c. Un script de boot avancé

Programmer

1. Création d’une bibliothèque

2. Les bibliothèques MicroPython

a. Bibliothèques standards et microbibliothèques

b. Bibliothèques spécifiques à MicroPython

c. Bibliothèque spécifique à l’ESP8266

d. Autres bibliothèques MicroPython

e. Mécanisme de chargement d’une bibliothèque

3. Charger et exécuter un script à la volée

4. RunApp : exécution conditionnelle de main.py

5. Entrées/sorties sur ESP8266

a. Entrée numérique

b. Entrée numérique (pull-up interne)

c. Entrée numérique et déparasitage logiciel

d. Sortie numérique

e. Entrée analogique

f. Ajout d’entrée/sortie avec MCP23017

g. Lecture analogique avec l’ADS1115

6. Senseur et interface sur ESP8266

a. Senseur PIR - senseur de proximité

b. Contact magnétique

c. DHT11 - humidité

d. Senseur à effet Hall

e. TSL2561 - luminosité

f. BME280 - température, humidité et pression barométrique

g. Module relais

MQTT sous ESP8266

1. Publication MQTT sous MicroPython

2. Souscription MQTT sous MicroPython

Asyncio sur ESP8266

1. Asyncio en quelques mots

2. Asyncio par l’exemple

3. Fonction run_every pour Asyncio

4. Plus d’informations sur Asyncio

Informations pratiques

1. Prérequis et configurations

2. LED de statut

3. Les topics MQTT

4. Télécharger et préparer le code des objets IoT

Fonctionnement général d’un objet IoT

1. Principales sections

2. Paramètres d’un objet IoT

3. RunApp et la LED d’activité

4. La fonction led_error()

5. Les tâches et fonctions asynchrones des objets IoT

Objet 1 : Météo cabane de jardin

1. Schéma de raccordement

2. Téléverser les scripts

3. Fonctionnement du script

4. Tester l’objet

Objet 2 : Surveillance salon

1. Téléverser les scripts

2. Fonctionnement du script

3. La fonction capture_1h()

4. Senseur PIR - variables et utilisation

5. Senseur PIR - la fonction pir_activated

6. Senseur PIR - la fonction pir_alert

7. Senseur PIR - la fonction pir_update

8. Problèmes de concurrence

9. Tester l’objet

Objet 3 : Surveillance de la véranda

1. Téléverser les scripts

2. Fonctionnement du script

3. La fonction capture_1h()

4. La fonction check_contact()

5. La fonction check_ldr()

6. Tester l’objet

Objet 4 : Chaufferie

1. Téléverser les scripts

2. Fonctionnement du script

3. La fonction capture_1h()

4. La fonction capture_10m()

5. La fonction check_mqtt_sub()

6. La fonction sub_cb()

7. La fonction chaud_exec_cmd()

8. Tester l’objet

Dépannage d’un objet IoT

Introduction

1. Pourquoi utiliser une base de données ?

2. Quel moteur de base de données ?

3. Principe de fonctionnement de push-to-db

SQLite 3

1. Présentation

2. Classe de stockage, type de données et affinité

a. Classe de stockage

b. Stockage des date et heure

c. Affinité de type pour les colonnes

d. Résolution de l’affinité de type

3. Affinité, expressions, comparaison et tri

a. Affinité des expressions

b. Comparaison, tri et groupage

4. Clé primaire et auto-incrément

a. Définir une clé primaire

b. Table rowid et clé primaire

5. SQLite3 et accès concurrents

6. Installation

a. Installer SQLite 3

b. Installer le support Python

7. Premiers pas avec SQLite3

a. Documentation SQL pour SQLite

b. Commandes de l’interpréteur SQLite

8. SQLite et Python

a. Opération de lecture SQLite

b. Opération d’insertion SQLite

c. Row Factory de SQLite

Approches techniques de push-to-db

1. Approche base de données de push-to-db

a. topicmsg - dernier message reçu

b. ts_xxx - historique de messages

2. Approche logicielle de push-to-db

a. Diagramme des classes (partie 1)

b. Fichier de configuration de push-to-db

c. Diagramme des classes (partie 2)

Configuration de push-to-db

1. Les répertoires de stockage de push-to-db

2. Création des tables de push-to-db

3. push-to-db.ini

4. Le script d’installation de push-to-db

Logger Python

1. Logger et fichier de configuration

2. Configuration du logger

3. Utilisation du logger

Exécution du script push-to-db

Service systemd pour push-to-db

1. Quand démarrer le service ?

2. Créer le fichier Unit

3. Configurer, démarrer, contrôler

4. Documentation sur systemd

Améliorations

Présentation de Flask

1. Pourquoi Flask ?

2. La flexibilité de Flask

3. Les nombreuses extensions Flask

4. Flask plus en détails

a. Werkzeug

b. WSGI

c. Application Flask

d. Jinja

e. Base de données

5. Documentations

Anatomie d’un projet Flask

Installation et prise en main

1. L’utilitaire flask

2. Prise en main avancée

3. Déboguer avec Flask

4. Application Flask en production

Les fondamentaux de Flask

1. Routes et paramètres

2. Retourner une erreur

3. Utilisation de template

4. Création d’URL

5. Redirection

6. Requêtes GET et POST

7. Contexte applicatif

a. L’objet g

b. Connexion à la base de données

8. Les cookies

9. Les sessions

10. Journalisation

11. Mini-projet Fruits

a. Sources du mini-projet

b. La connexion SQLite 3

c. Organisation du mini-projet

d. Détails du mini-projet

12. Ressources et documentations

Templates Jinja

1. Exécution d’un template

2. Tester un template

a. Créer une application Flask

b. Test avec serveur web Flask et string Python

c. Test en console et string Python

d. Utiliser le projet Jinja Live Parser

3. Évaluation des balises

a. {{ … }} : évaluation d’expression

b. {% … %} : instructions de contrôle de flux

c. {# … #} : insertion de commentaire

d. # …  : ligne d’instruction

4. Variables et expressions

a. Variables spéciales

b. Séquence d’échappement

c. Assignation

5. Branchement

6. Itération

7. Les macros

8. Contrôle des espaces

9. Filtres Jinja

10. Inclusion de template

11. Importer des macros

12. Héritage de template

a. Les éléments de l’héritage

b. Heritage-app : l’héritage Jinja par la pratique

c. Template de base et block

13. Template enfant

a. Super bloc

b. Ressources

14. Message Flash

Présentation

1. Préambule

2. Dépôt du projet Dashboard

3. Éléments principaux

4. Fonctionnalités du projet Dashboard

Structure HTML

1. Disposition de la page

2. Les blocs d’informations

3. La liste

Template Jinja

1. Le template de base

2. Utilisation du template de base

Configuration

1. Base de données dashboard.db

a. Schéma de la base de données

b. Répertoire de stockage

c. Création des tables de Dashboard

d. Copie de la base de données

2. Fichier de configuration de Dashboard

Détails de l’application Flask

1. Répertoires et fichiers

2. Les routes de Dashboard

3. Accès aux données

a. La fonction get_db( db_key ) multi bases de données

b. Les classes DBHelper de Dashboard

c. Exemple : liste des topics disponibles pour Dashboard

d. Exemple : extraction de l’historique dans Dashboard

e. Affichage d’un tableau de bord

4. Les filtres Jinja personnalisés

5. Affichage du tableau de bord

6. Les macros Jinja

a. La macro make_block

b. La macro block_icon

c. La macro block_big_text

d. La macro select_color (édition d’un bloc)

Bloc switch (marche/arrêt)

1. Développements complémentaires

a. MQTT sources

b. Bloc et paramètres additionnels

2. Ajout du bloc SWITCH

a. Block_config du switch

b. Ajouter le nouveau type de bloc

3. Le switch et MQTT

a. Client MQTT JavaScript

b. MQTT en JavaScript et WebSocket

c. Activer le support WebSocket sur Mosquitto

d. Tester le client MQTT JavaScript

e. Mille milliards de mille sabords !

f. La route MqttProxyPublish

g. Événement on_switch_change

4. Tester le bloc switch

Améliorations

Installation rapide

1. Prérequis

2. Début de l’installation

3. Récupération des sources

4. Poursuivre l’installation

 

Matériel utilisé

La liste ci-dessous reprend différents éléments exploités dans le projet ou dans l’ouvrage. Chaque élément est accompagné d’une petite description.

1. Raspberry Pi

images/01RI03.pngimages/01RI03.png
 

Raspberry Pi 3

Le Raspberry Pi est certainement le nano-ordinateur le plus célèbre du monde.

À peine plus grand qu’une carte de crédit, le Raspberry Pi est un formidable outil d’apprentissage et une excellente base pour le développement de solutions amateur et semi-professionnelles.

Propulsé par 4 cœurs à 1 GHz, 1 gigaoctet de RAM et Linux, le Raspberry Pi dispose d’interfaces Wi-Fi et Bluetooth, de 4 ports USB, d’une interface Ethernet et d’un GPIO à 40 broches permettant de brancher une multitude de périphériques et de matériels électroniques.

2. Feather ESP8266 Huzzah

images/01RI04.pngimages/01RI04.png
 

Feather ESP8266 Huzzah

L’ESP8266 est un microcontrôleur Wi-Fi et probablement la plateforme la plus célèbre après l’Arduino UNO (la référence en programmation de microcontrôleurs dans le monde des makers). 

L’ESP8266 basé sur le module ESP12S se retrouve sur de nombreuses plateformes de développement comme Feather, Wemos, NodeMCU. C’est la version Feather ESP8266 d’Adafruit Industries qui a été sélectionnée dans cet ouvrage. Adafruit Industries dispose d’un large réseau de distribution et de produits fiables. Le module Feather exploite un module ESP certifié et les cartes restent identiques d’une livraison à l’autre (ce qui n’est pas forcément le cas des produits directement commandés en Chine). Autre point important, cette version Feather de l’ESP8266 dispose d’un auto-reset pour activer le mode flash sans avoir à manipuler de bouton.

Feather est également un écosystème de cartes et d’extensions plus intéressantes les unes que les autres. La gamme Feather d’Adafruit est accessible directement sur : https://www.adafruit.com/category/943

Hormis des produits de qualité, Adafruit Industries dispose d’une grande communauté et d’un excellent support technique.

3. Module relais

images/01RI05.pngimages/01RI05.png
 

Les modules relais pré-assemblés permettent de commander facilement des objets de notre quotidien. Ils agissent comme des interrupteurs commandés par un microcontrôleur ou un nano-ordinateur.

 

Si le module relais permet de travailler facilement avec des appareils connectés sur le réseau domestique, il est important de mentionner que l’utilisation d’une tension supérieure à 30 V peut devenir extrêmement dangereuse. Les risques d’électrocution sont réels et peuvent, dans certains cas, conduire à un arrêt cardiaque ! La manipulation de circuits haute tension, y compris du réseau domestique, doit être réservée aux personnes disposant du savoir-faire adéquat.

4. DHT11 - Humidité

images/01RI06.pngimages/01RI06.png
 

Senseur d’humidité DHT11

Le DHT11 est un senseur d’humidité très bon marché souvent utilisé par les makers. Il permet de relever l’humidité relative entre 20 et 80 %. Le senseur mesure la température pour ajuster les mesures effectuées, information également fournie par le senseur. À noter que l’humidité relative dépend de la température, car l’air peut emmagasiner plus d’humidité si la température augmente.

 

Le vieillissement de ce type de senseur est une caractéristique méconnue par ses utilisateurs. En effet, la mesure de l’humidité est réalisée par un effet capacitif, ce qui implique qu’une partie du senseur doit être en contact avec l’air ambiant. Il y a donc des phénomènes d’oxydation qui entrent en compte et qui provoquent le vieillissement du senseur.

5. AM2315 - Température et humidité

images/01RI07.pngimages/01RI07.png
 

Senseur AM2315

Le senseur AM2315 fonctionne de façon similaire au DHT11 à la différence que celui-ci offre un relevé entre 0 et 100 % d’humidité relative et embarque également un senseur de température numérique. À placer à l’abri des intempéries (il n’est pas weather proof), ce senseur peut effectuer des relevés en extérieur.

6. DS18B20 - Température

images/01RI08.pngimages/01RI08.png
 

Senseur DS18B20

Le senseur DS18B20 est un senseur numérique utilisant un bus de données 1-Wire qui permet de placer plusieurs senseurs sur un même bus. Le DS18B20 est populaire dans le monde des makers et régulièrement exploité dans le monde professionnel. Ce composant est utilisé sur une grande variété de plateformes. Disponible sous forme d’un composant brut ressemblant à un transistor, le DS18B20 est également distribué sous forme de sonde (dans un capuchon en inox) permettant de relever la température en de nombreux endroits.

7. BME280 et BMP280 - Pression, humidité, température

images/01RI90.pngimages/01RI90.png
 

Senseur BME280 en breakout

images/01RI91.pngimages/01RI91.png
 

Senseur BMP280 en breakout

Le BMP280 est un senseur environnemental permettant de relever la température et la pression atmosphérique. C’est donc un composant idéal pour réaliser des relevés météorologiques.

Le BME280 est une évolution du BMP280 permettant, en plus, de faire un relevé d’humidité relative.

8. TSL2561 - Luminosité

images/01RI93.pngimages/01RI93.png
 

Senseur TSL2561 en breakout

Le TSL2561 permet d’effectuer un relevé de luminosité mesuré en Lux. Le TSL2561 utilise un double senseur interne autorisant un relevé du spectre entier et du spectre infrarouge. Ce senseur dispose donc des informations adéquates pour être capable de fournir une réponse proche de celle de l’œil humain.

9. ADS1115 - Lecture analogique

images/01RI94.pngimages/01RI94.png
 

Convertisseur ADC ADS1115 en breakout

L’ADS1115 est un convertisseur analogique vers numérique, ce qui lui permet de lire des tensions analogiques. L’ADS1115 dispose d’un amplificateur à gain programmable, ce qui permet à la carte de lire de très faibles tensions. L’ADS1115 propose des relevés d’une excellente précision et supporte également un mode différentiel permettant de lire la différence de tension entre deux broches du convertisseur.

L’ADS1115 est un composant idéal pour offrir des entrées analogiques à un Raspberry Pi ou un ESP8266. Sur un Arduino, il offrira des entrées analogiques avec une bien meilleure précision que celle offerte par le microcontrôleur Atmel.

10. TMP36 - Température

images/01RI96.pngimages/01RI96.png
 

Senseur TMP36

Le TMP36 est un senseur de température analogique bon marché et très populaire dans le monde Arduino. Ce composant fournit une tension analogique proportionnelle à la température. Le TMP36 s’utilise conjointement avec un ADS1115 sur un Raspberry Pi ou un ESP8266.

11. Photorésistance - luminosité

images/01RI97.pngimages/01RI97.png
 

Photorésistance

La photorésistance, également appelée LDR, est un composant dont la résistance varie en fonction des conditions de luminosité. Un tel composant n’offre pas de mesure précise, mais permet de relever des conditions de luminosité relatives. Il peut être utilisé pour savoir si la lumière est allumée dans une pièce, s’il fait jour ou nuit ou toute autre mesure impliquant une forte modification des conditions de luminosité.

12. PIR - Détection de mouvement

images/01RI98.pngimages/01RI98.png
 

Senseur PIR

Le senseur PIR est utilisé pour réaliser la détection de mouvement. Celui employé dans cet ouvrage est un senseur autonome avec sortie numérique. Ce modèle est très simple à utiliser, il active une sortie pendant plusieurs secondes lorsqu’un mouvement est détecté. Un tel senseur est généralement équipé de deux potentiomètres : un premier potentiomètre permet de régler la sensibilité du senseur tandis que le deuxième potentiomètre règle le temps d’activation de la sortie numérique.

13. Contact magnétique

images/01RI99.pngimages/01RI99.png
 

Contact magnétique

Le contact magnétique est composé de deux éléments : un interrupteur reed (sensible au champ magnétique) et un aimant. Lorsque l’aimant s’éloigne, l’interrupteur magnétique s’ouvre, ce qui permet d’interrompre un circuit électrique. Lorsque l’aimant revient près de l’interrupteur alors celui-ci se referme.

Les contacts magnétiques sont utilisés pour détecter l’ouverture d’une porte, d’un tiroir, d’une cache secrète, etc.

14. Senseur à effet Hall numérique

images/01RI95.pngimages/01RI95.png
 

Senseur à effet Hall

Le senseur à effet Hall permet de détecter la présence d’un champ magnétique. Ce senseur existe avec une sortie analogique ou une sortie numérique. Un senseur analogique permet de faire des relevés d’intensité de champ magnétique (hors cadre du présent ouvrage) tandis que le senseur numérique permet de relever la présence (ou l’absence) du champ magnétique.

Le senseur à effet Hall numérique s’utilise généralement avec un aimant et permet de réaliser des interrupteurs sans contact. Il est ainsi possible de réaliser une fin de course avec un aimant, un compte tour, un détecteur de niveau d’eau en plaçant l’aimant sur un flotteur, un détecteur sans contact (très pratique pour détecter l’ouverture d’une poubelle) ou un interrupteur masqué. 

Code source

1. Téléchargement

Le code source du projet est disponible sous forme d’une archive depuis la page Informations générales.

Cette archive contient une version figée du projet dans l’état où il était au moment de l’édition du présent ouvrage.

images/01RI09.pngimages/01RI09.png
 

L’archive contenant le code source

Cette version correspond scrupuleusement aux explications disponibles dans l’ouvrage.

L’archive peut être extraite dans le répertoire utilisateur du Raspberry Pi (soit /home/pi/) à l’aide de la commande :

unzip -e LFPYRASPFL.zip

2. GitHub

Le projet a entièrement été développé dans le dépôt la-maison-pythonic disponible sur GitHub. Ce projet connaîtra certainement d’autres évolutions après la parution de l’ouvrage.

Durant la lecture de l’ouvrage, il est recommandé d’utiliser l’archive disponible depuis la page Informations générales.

Après la lecture de l’ouvrage, le lecteur pourra profiter des dernières avancées en téléchargeant la version disponible sur GitHub.

La version GitHub est disponible ici : https://github.com/mchobby/la-maison-pythonic

images/01RI10.pngimages/01RI10.png
 

GitHub du projet « la-maison-pythonic »

Le contenu du GitHub peut être cloné sur le Raspberry Pi avec la commande :

git clone https://github.com/mchobby/la-maison-pythonic.git

images/01RI11.pngimages/01RI11.png
 

Clonage du projet GitHub sur le Raspberry Pi

Configuration

Ce livre utilise un Raspberry Pi comme élément central du développement. Sa configuration est donc un point important.

Pour simplifier les étapes de configuration, le Raspberry Pi sera démarré avec un système d’exploitation Raspbian (Linux) pleinement fonctionnel et donc avec un environnement de bureau, ce qui est plus confortable pour les nouveaux venus.

Cela étant, tout au long du livre, la ligne de commande et la connexion SSH seront surtout exploitées. SSH permet de disposer d’une ligne de commande sur le Raspberry Pi depuis un ordinateur distant.

1. Installation du Raspberry Pi

Préparer la carte micro SD

Flasher la carte micro SD avec le système d’exploitation Raspbian Stretch (ou plus récent). Le système d’exploitation peut être téléchargé depuis le site de la fondation Raspberry Pi (https://www.raspberrypi.org). Pour flasher la carte SD, le logiciel Etcher (https://etcher.io/) est un excellent outil libre fonctionnant sur les systèmes d’exploitation Linux, Windows et macOS. 

images/01RI50.pngimages/01RI50.png
 

Flasher le système d’exploitation Raspbian sur la carte micro SD

Premier démarrage et configuration de base

Brancher un clavier et une souris. Insérer la carte micro SD dans le Raspberry Pi, brancher un moniteur HDMI (ou télévision) et le câble réseau puis, finalement, mettre le Raspberry Pi sous tension.

Le système d’exploitation démarre, affiche différents messages puis finalement l’environnement graphique et le bureau Pixel.

Les versions récentes de Pixel proposent de configurer les paramètres régionaux dès le premier démarrage, ce qui est très pratique.

images/01RI51a.pngimages/01RI51a.png
 

Invitation à saisir les paramètres régionaux au premier démarrage

 

Il est possible de redémarrer l’outil de configuration à tout moment en saisissant sudo piwiz.

Le premier écran concerne les paramètres régionaux.

 Sélectionnez les paramètres adéquats avant de passer à l’écran suivant en pressant le bouton Next.

images/01RI51b.pngimages/01RI51b.png
 

Sélectionner les paramètres régionaux

 Vient ensuite l’initialisation du mot de passe de l’utilisateur « pi » (utilisateur par défaut).

images/01RI52.pngimages/01RI52.png
 

Saisie du mot passe de l’utilisateur pi

 

Il est important de saisir un mot de passe suffisamment long et complexe. La case à cocher Hide Passwords peut être décochée pour afficher le mot de passe saisi au clavier. Cela permet, entre autres, de vérifier que la disposition des touches du clavier est correcte.

La configuration se poursuit avec la sélection d’un réseau Wi-Fi. Ce point est ignoré étant donné que le Raspberry Pi, utilisé comme serveur, sera raccordé au réseau par l’intermédiaire d’une connexion Ethernet filaire.

 Pressez le bouton Skip.

images/01RI53.pngimages/01RI53.png
 

Configuration Wi-Fi du Raspberry Pi

 La dernière étape de la configuration propose de Vérifier la disponibilité de mise à jour. Il est vivement conseillé de procéder aux mises à jour en pressant le bouton Next.

images/01RI54.pngimages/01RI54.png
 

Vérification des mises à jour

 

Cette opération peut prendre plusieurs dizaines de minutes. Une première mise à jour de 30 à 40 minutes n’a rien d’exceptionnel ! C’est le moment de profiter d’un morceau de tarte aux framboises et d’une bonne tasse de café.

 

L’économiseur d’écran étant actif, l’écran peut devenir noir durant le processus de mise à jour. Presser la touche [Shift] permet de réactiver l’affichage.

Une fois la mise à jour achevée, une boîte de dialogue annonce que le système est à jour.

images/01RI55.pngimages/01RI55.png
 

Fin de la mise à jour

 Une fois la mise à jour terminée, l’utilisateur est invité à redémarrer son Raspberry Pi.

images/01RI56.pngimages/01RI56.png
 

Invitation à redémarrer

 Pressez le bouton Reboot pour redémarrer le Raspberry Pi.

Adresse IP fixe

La résolution du nom d’hôte n’est pas toujours une science exacte sur les réseaux domestiques propulsés par des box Internet. Si le fait de pouvoir accéder à une machine distante sur la base de son nom est fiable la plupart du temps, il arrive parfois que - sans aucune raison et pour une durée indéterminée - ce service devienne totalement instable.

Le Raspberry Pi servant aussi de serveur pour collecter les données des objets Internet, il est vivement recommandé de lui assigner une adresse IP fixe. D’autant plus que mDns ne sera pas disponible sur nos objets IoT.

L’assignation de l’adresse IP fixe du Raspberry Pi peut se faire :

1.

Par l’intermédiaire du serveur DHCP qui assigne l’adresse IP fixe pour l’adresse MAC du Raspberry Pi (voir la documentation du modem-routeur).

2.

En modifiant la configuration IP directement sur le Raspberry Pi (point abordé ci-dessous).

Lorsque le Raspberry Pi est connecté sur le réseau Ethernet, le coin supérieur droit du bureau affiche une icône contenant une double flèche. Cette icône permet d’accéder à la configuration de la connexion réseau (voir l’entrée Wireless & Wired Network Settings dans le menu contextuel).

images/01RI45.pngimages/01RI45.png
 

Menu contextuel pour accéder à la configuration réseau

Dans la fenêtre de configuration des préférences réseau, il est nécessaire de sélectionner l’interface eth0 correspondant à la connexion filaire.

images/01RI46.pngimages/01RI46.png
 

Configuration réseau

Le réseau local utilisé exploite des adresses de classe C avec une adresse de base 192.168.1.x. Le modem-routeur utilise l’adresse 192.168.1.1.

L’adresse IP statique du Raspberry Pi est choisie arbitrairement dans la gamme d’adresses disponibles. Dans le cadre de cet ouvrage (et les codes des exemples), l’adresse choisie est 192.168.1.210. L’adresse du routeur 192.168.1.1 est également mentionnée pour permettre au Raspberry Pi d’accéder aux ressources disponibles sur Internet, ce qui permet d’installer des paquets logiciels.

 

Il peut être nécessaire d’adapter les adresses IP utilisées en fonction de la configuration du réseau en cours d’utilisation.

 Redémarrez le Raspberry Pi après avoir fixé l’adresse IP.

Activer le serveur SSH

 Après le redémarrage du Raspberry Pi, les premières choses à faire sont d’activer le serveur SSH et d’altérer la configuration du système.

Le serveur SSH permet d’établir une ligne de commande à distance depuis un ordinateur, un environnement de travail souvent beaucoup plus confortable qu’un clavier et une souris branchés sur le Raspberry Pi où il faut saisir toutes les commandes à la main.

 Pressez l’icône présentant les symboles « >_ » dans la barre de menu pour démarrer un terminal.

images/01RI60.pngimages/01RI60.png
 

Activer un terminal dans l’environnement bureau

 Ensuite, saisissez la commande permettant de démarrer l’utilitaire de configuration.

sudo raspi-config

 Puis sélectionnez le point de menu Interfacing Options (options d’interfaçage).

images/01RI61.pngimages/01RI61.png
 

Menu principal de raspi-config

 Puis sélectionnez l’entrée SSH pour activer le serveur SSH.

images/01RI62.pngimages/01RI62.png
 

Options d’interfaçage de raspi-config

L’outil de configuration demande s’il faut activer le serveur SSH (Would you like the SSH server to be enabled?).

 Sélectionnez le bouton <Oui> pour activer le serveur SSH.

images/01RI63.pngimages/01RI63.png
 

Activation du serveur SSH dans raspi-config

images/01RI64.pngimages/01RI64.png
 

Confirmation d’activation du serveur SSH sur le Raspberry Pi

Une fois le service SSH activé et le Raspberry Pi redémarré, il est possible de reléguer le Raspberry Pi sur une étagère sans moniteur, ni clavier, ni souris.

Quelques autres options vont être modifiées dans l’utilitaire raspi-config avant de redémarrer le Raspberry Pi.

Modifier le nom d’hôte

Par défaut, le nom d’hôte du Raspberry Pi sur le réseau est « raspberrypi ».

Cette configuration par défaut est tout à fait correcte tant qu’il n’y a qu’un seul Raspberry Pi branché sur le réseau. Dans le cas contraire, plusieurs machines portent le même nom, ce qui complique singulièrement l’identification d’une machine précise.

Dans le cadre de ce projet, le nom d’hôte du Raspberry Pi est fixé à « pythonic ». Il est vivement conseillé d’utiliser ce nom d’hôte durant la phase d’apprentissage.

Il permet d’établir une connexion SSH, une connexion FTP ou une connexion web en précisant le nom d’hôte sur le réseau local (ex. : pythonic.local) plutôt que l’adresse IP (ex. : 192.168.1.210).

 Sélectionnez l’option Network Options dans le menu principal de raspi-config.

images/01RI65.pngimages/01RI65.png
 

Sélection des options réseau dans raspi-config

 Puis sélectionnez l’option Hostname pour modifier le nom d’hôte pour le Pi.

images/01RI66.pngimages/01RI66.png
 

Sélection de l’option Hostname

images/01RI67.pngimages/01RI67.png
 

Modification du nom d’hôte

Options de démarrage

La configuration du Raspberry Pi se poursuit afin que celui-ci soit plus en adéquation avec les développements qui s’annoncent. En effet, un bureau graphique est peu pertinent dans le cadre du présent ouvrage. Il sera donc désactivé et la mémoire GPU réduite au strict minimum.

Sélectionnez l’entrée Boot Options (option de démarrage) dans le menu principal.

images/01RI68.pngimages/01RI68.png
 

Options de démarrage dans Raspi-Config

 Puis sélectionnez l’entrée Desktop / CLI (Bureau/Interface en ligne de commande) pour configurer l’interface de démarrage du Raspberry Pi.

images/01RI69.pngimages/01RI69.png
 

Sélection de l’interface principale du Raspberry Pi dans raspi-config

 Enfin, sélectionnez l’entrée Console afin de ne plus démarrer l’interface graphique tout en préservant un login protégeant le système.

images/01RI70.pngimages/01RI70.png
 

Activation de la console dans raspi-config

 

Il est possible de démarrer l’environnement graphique à tout moment en saisissant la commande startx dans le terminal.

Réduire la mémoire GPU

La mémoire allouée au GPU (processeur graphique) peut être réduite au strict minimum étant donné que l’interface graphique n’est pas utilisée. Cela fait autant de mémoire récupérée pour le fonctionnement des processus.

 Sélectionnez l’entrée Advanced Options (options avancées) dans le menu principal de raspi-config. 

images/01RI71a.pngimages/01RI71a.png
 

Menu principal de raspi-config

 Sélectionnez l’entrée Memory Split (séparation de la mémoire) dans les options avancées.

images/01RI71b.pngimages/01RI71b.png
 

Options avancées du menu raspi-config

 Dans l’écran de configuration Memory Split, saisissez la valeur minimale autorisée, soit 16 Mb avant de confirmer la valeur avec le bouton Ok.

images/01RI72.pngimages/01RI72.png
 

Saisie de la quantité de mémoire allouée au GPU

Redémarrer le Raspberry Pi

 Une fois la configuration achevée, l’outil raspi-config propose de redémarrer le système puisque des paramètres importants ont été modifiés.

images/01RI73.pngimages/01RI73.png
 

Outil raspi-config proposant le redémarrage du Pi

Après ce dernier redémarrage, le Raspberry Pi est prêt pour débuter la découverte du projet.

2. Utilitaires : des outils pour travailler

Une fois SSH activé, plusieurs outils peuvent être exploités pour épauler les développements sur Raspberry Pi.

a. Connexion SSH

Windows & Linux

PuTTY est un émulateur de terminal (logiciel libre). Il supporte de nombreux protocoles comme rlogin, Telnet et SSH. PuTTY a l’avantage de supporter les connexions TCP et les liaisons séries.

images/01RI74.pngimages/01RI74.png
 

Connexion SSH sur Raspberry Pi via PuTTY

PuTTY fonctionne aussi bien sous Windows que sous Linux.

PuTTY peut être téléchargé depuis https://www.putty.org/.

Linux et Mac OSX

Pour les systèmes d’exploitation Linux et Mac, l’utilitaire en ligne de commande ssh peut être utilisé dans un terminal pour établir une connexion SSH avec un système distant.

La syntaxe à utiliser est sshutilisateur@hote, ce qui se traduit par ssh pi@pythonic.local ou ssh pi@192.168.1.210 pour atteindre le Raspberry Pi fraîchement installé.

images/01RI75.pngimages/01RI75.png
 

Connexion SSH avec le Raspberry Pi depuis un terminal Linux

b. Éditeur de texte Nano

Hormis la saisie de lignes de commande, la consultation de fichiers journaux, la connexion SSH est très utile pour modifier des paramètres dans les fichiers de configuration. C’est là qu’intervient Nano, un outil qui deviendra vite indispensable !

Nano est un mini éditeur de texte simple et efficace qui fonctionne en mode terminal et donc aussi via SSH. Nano affiche le contenu du fichier en plein écran, permet de naviguer dans le contenu en utilisant le curseur clavier.

Nano n’inclut pas de menu dans son interface, mais utilise des combinaisons de touches pour offrir des options de commandes. Par exemple, la notation « ^X Quitter » indique qu’il faut presser simultanément les touches [Ctrl] X pour quitter l’éditeur.

Pour lancer l’éditeur de texte, il suffit de saisir la commande nano nom_de_fichier dans le terminal.

L’exemple ci-dessous montre une fenêtre de terminal présentant une connexion ssh vers pythonic.local (le Raspberry Pi) où l’utilitaire nano édite le contenu du fichier dashboard.service.sample avec la commande :

nano la-maison-pythonic/python/dashboard/install

Commande saisie dans la connexion SSH établie avec le Raspberry Pi.

images/01RI78.pngimages/01RI78.png
 

Éditeur de texte Nano dans un terminal

La popularité de Nano fait qu’il est facile de trouver des informations sur Internet, informations qui peuvent être complétées par les pages de manuel (voir commande man nano) ou le message d’aide de Nano (voir commande nano --help).

c. Transfert de fichiers via SSH (sftp)

Des utilitaires libres comme FileZilla et Bitvise SSH Client permettent de transférer facilement des fichiers via SSH. Ces utilitaires établissent une connexion SSH avec l’hôte distant puis ils permettent la navigation dans le système de fichiers hôte et offrent des services d’envoi ou de récupération de fichiers.

Aucune installation spécifique n’est requise côté Raspberry Pi (dit « côté serveur »). Les clients FileZilla et Bitvise peuvent être utilisés directement avec le Pi.

 

Le transfert de fichiers est un complément indispensable pour faciliter le développement et la maintenance à distance.

Linux, Windows et Mac OSX

Le client FileZilla est disponible pour une grande variété de plateformes en libre téléchargement sur https://filezilla-project.org.

Une fois installé, établir une connexion avec le Raspberry Pi fraîchement installé se fait en saisissant les paramètres suivants :

images/01RI76.pngimages/01RI76.png
 

Connexion sur le Raspberry Pi à l’aide de FileZilla

C’est la mention du port 22 qui fait basculer FileZilla en FTP via SSH (sftp).

La mention de l’hôte pythonic.local peut être remplacée par l’adresse IP du Raspberry Pi (soit 192.168.1.210 dans le cas présent).

images/page38.pngimages/page38.png
 

Interface de FileZilla

Une fois la connexion établie, FileZilla propose une interface en deux volets :

Le transfert de fichiers et de répertoires est déclenché par un simple glissé/déposé.

FileZilla propose également une option de mise à jour automatique vers le système distant lorsque le fichier local est modifié.

Windows uniquement

Bitvise SSH Client pour Windows est un logiciel offrant à la fois un client SSH et un outil de transfert SFTP.

Déjà utilisé dans le cadre professionnel, cet outil gratuit est une alternative intéressante à FileZilla. 

Pour plus d’informations, voir https://www.bitvise.com/ssh-client-download.

d. Système de fichiers SSH

Les heureux possesseurs d’une machine Linux pourront profiter d’une fonctionnalité très pratique : le montage de système de fichiers via SSH.

sshfs (secure file system), permet d’accéder à un système de fichiers distant à partir d’un répertoire local. De façon totalement transparente pour l’utilisateur, toute opération réalisée dans le répertoire local est en fait effectuée sur le système de fichiers de la machine distante.

Cela permet de travailler avec l’environnement de développement de l’ordinateur local sur les fichiers stockés dans l’hôte distant. Une bonne partie du code du dashboard et de push-to-db a été développée avec l’aide de sshfs.

En ligne de commande

Les lignes de commandes suivantes permettent de créer un répertoire utilisateur nommé « pythonic » puis de monter le système de fichiers distant via sshfs dans le répertoire « pythonic ».

$ cd ~  

$ mkdir pythonic  

$ sshfs -o idmap=user pi@192.168.1.210:/home/pi pythonic  

pi@192.168.1.210’s password: *******  

$ ls pythonic

Dans la commande sshfs, le paramètre pi@192.168.1.210:/home/pi mentionne l’utilisateur (pi), la machine distante (192.168.1.210), le répertoire de destination sur l’hôte (/home/pi) et le paramètre pythonic correspond au répertoire local sur lequel le système de fichiers distant sera monté.

À noter que le mot de passe de l’utilisateur pi sur l’hôte distant doit être saisi afin d’établir la connexion.

Une fois le mot de passe saisi, le système de fichier distant est accessible en explorant le contenu du répertoire « pythonic ».

images/01RI79.pngimages/01RI79.png
 

Montage et exploration d’un système de fichiers distant via sshfs

Le système de fichiers peut être démonté à tout moment en utilisant la commande fusermount -u pythonic.

Via l’interface graphique

Certains environnements Linux permettent de monter un système de fichiers sshfs. Par exemple, le navigateur de fichiers Linux Mint offre un point de menu Se connecter à un serveur....

images/01RI80.pngimages/01RI80.png
 

Point de menu pour réaliser une connexion sshfs

Ce qui affiche une boîte de dialogue permettant d’établir une connexion vers différents types de systèmes de fichiers, dont « ssh ».

Dans la capture ci-dessous, la connexion est configurée à l’identique de l’exemple en ligne de commande.

images/01RI81.pngimages/01RI81.png
 

Configuration du montage sshfs

Ce qui rend le système distant accessible depuis le navigateur de fichiers.

images/01RI82.pngimages/01RI82.png
 

Affichage du contenu distant

e. Bureau à distance

Bien que hors sujet, il existe également la possibilité de prendre le contrôle à distance du bureau Raspberry Pi en mode graphique à l’aide de VNC (Virtual Network Computing).

L’utilitaire raspi-config contient un point d’entrée VNC dans le menu Interfacing Options. Cette option permet d’activer un serveur VNC au démarrage du Raspberry Pi.

images/01RI61.pngimages/01RI61.png
 

Menu principal de l’utilitaire

images/page43.pngimages/page43.png
 

Activation du serveur VNC (point P3) dans l’utilitaire raspi-config

Grâce à VNC, il est possible de contrôler le bureau Raspberry Pi à distance en utilisant un client VNC.

L’utilisation de VNC est développée en détail dans le livre de référence « Raspberry Pi 3 et Pi Zero » au chapitre Se connecter à distance au Raspberry Pi. Livre de François Mocq, paru aux Éditions ENI.

Type de données collectées

Le projet met en œuvre des objets IoT effectuant des mesures à l’aide de composants électroniques, mesures qui se traduiront par des relevés télémétriques.

Les données collectées sont les suivantes :

 

Présentation et concepts

Derrière MQTT et le « broker MQTT » se cache une technologie très intéressante et plutôt simple à appréhender.

Étant donné que le broker MQTT est un élément central du projet, ce chapitre va s’attarder sur les concepts importants permettant d’appréhender un système MQTT dans son ensemble. La connaissance acquise sera facilement ré-exploitable dans des projets personnels et professionnels.

Le broker MQTT est un élément logiciel qui permet de mettre en relation des sources de données (senseur, logiciel, etc.) désirant publier des informations vers des clients qui utilisent un mécanisme de souscription pour recevoir ces informations.

images/02RI02.pngimages/02RI02.png
 

Réseau MQTT

MQTT est un protocole conçu au début des années 2000 pour des réseaux de communications à faible bande passante (ligne téléphonique, transmission radio) et temps de latence important. MQTT se présente surtout comme un élément de base fiable permettant d’échafauder des solutions robustes.

Le graphique ci-dessus reprend des sources publiant des données/messages vers un broker. Ce dernier agit comme un distributeur et renvoie ces données/messages vers les clients ayant demandé à être notifiés par souscription.

La fiabilité et la robustesse de MQTT sont dues aux choix clés opérés durant la conception du protocole :

1. Le broker MQTT, élément central du réseau MQTT

Un broker MQTT est un logiciel permettant d’échanger des messages entre différents intervenants en utilisant le protocole MQTT. MQTT est l’acronyme de MQ Telemetry Transport, un protocole léger destiné au transport de données télémétriques (pression, température, événement…) de machine à machine. MQTT s’appuie sur le protocole TCP/IP utilisé pour les communications sur les réseaux locaux et Internet. La simplicité du protocole MQTT en fait un candidat idéal pour les projets IoT (Internet of the Things pour Internet des objets) et, de fait, il existe de très nombreuses implémentations du protocole MQTT supportées par de nombreuses plateformes matérielles, de nombreux systèmes d’exploitation et de nombreux langages de programmation.

images/02RI01.pngimages/02RI01.png
 

Exemple d’échanges de messages entre différents intervenants

Le protocole est basé sur le modèle publisher/subscriber (publication/souscription). Dans ce modèle de communication, les relevés télémétriques (les mesures) sont publiés sous forme de messages dans des sujets (de discussion) sur le broker MQTT. Les clients intéressés par ces informations souscrivent un abonnement sur ledit sujet afin d’être notifiés du contenu des différentes publications. Pour terminer, le protocole MQTT prévoit une notation hiérarchique pour organiser les sujets (cette hiérarchie permet de distinguer les différentes données télémétriques les unes des autres).

2. Les éléments de MQTT

Les explications qui suivent reprennent principalement la terminologie anglaise, termes qui sont largement employés dans les différents logiciels, les interfaces de programmation et la documentation en ligne.

Les échanges MQTT font intervenir les concepts de :

Le fonctionnement du système dans son ensemble peut être, dans une certaine mesure, comparé à l’utilisation d’un forum de discussion sur Internet.

3. Le broker MQTT

Dans le cas d’une comparaison avec les forums, le broker sera la partie logicielle du serveur gérant les connexions, les comptes utilisateurs, les différents sujets. Ces sujets sont organisés et hiérarchisés en catégories et sous-catégories. Le logiciel serveur prendra en charge la réception d’un message sur le sujet donné et s’occupera de la propagation dudit message auprès des utilisateurs ayant demandé à être notifiés de toute publication sur le sujet.

À noter qu’il y a une différence fondamentale entre un serveur MQTT et un forum. Tout l’historique est préservé lorsque le serveur forum redémarre, ce qui n’est pas le cas d’un serveur MQTT (sauf pour une implémentation particulière). Dans le cas d’un serveur MQTT, tous les sujets disparaissent, toutes les souscriptions sont abandonnées.

Un serveur MQTT est avant tout un aiguilleur de messages. Il permet de gérer des comptes utilisateurs, des droits d’accès, etc.

Comme le laisse entendre l’illustration en début de chapitre, un serveur MQTT peut être localisé sur un serveur dans le Cloud tout comme il peut être installé sur un serveur du réseau d’entreprise ou un nano ordinateur tel que le Raspberry Pi sur un réseau domestique.

4. Les topics

Comme sur un forum, les informations sont organisées en sujets (dits topics en anglais), aussi appelés fils de discussion. Plusieurs intervenants peuvent interagir sur un sujet (en y envoyant des messages) et plusieurs intervenants peuvent suivre le déroulement des conversations. Il est commun sur les forums de pouvoir s’abonner à un sujet et d’être notifié de toute nouvelle information publiée sur ledit sujet (fonctionnement typique de MQTT).

Sur les forums, les discussions sont organisées en catégories, voire sous-catégories et en sujets. L’organisation est similaire en MQTT.

Sur un broker MQTT, tout le monde peut créer un topic, publier des messages sur un topic et consulter les publications sur les différents topics disponibles.

Voici quelques exemples de topics :

projet/air-qualite/paris/mod-c724fd5680a7/particules 

taxi/flotte/eda024/vitesse 

taxi/flotte/eda024/position 

taxi/flotte/eda024/direction 

Maison/Cave/Humidite

5. Les publishers

Derrière le terme publishers (de to publish signifiant « publier ») se cachent les systèmes matériels publiant des informations vers le broker MQTT.

Sur un forum, le publisher serait un utilisateur envoyant un message dans un fil de discussion. L’utilisateur à la possibilité d’écrire plusieurs messages, dans plusieurs sujets différents.

Les publishers MQTT sont principalement des senseurs connectés ou des processus de surveillance envoyant des données télémétriques.

Le publisher envoie un message sur un topic particulier. Bien que le message soit une chaîne de caractères, le publisher peut convertir une valeur entière ou une valeur en virgule flottante en chaîne de caractères (représentation textuelle) avant son envoi dans le message MQTT.

Un publisher n’est pas limité à un seul topic, il peut envoyer plusieurs messages distincts sur différents topics.

Sauf application de règles de sécurité particulières, les topics sont automatiquement créés sur le broker MQTT si ceux-ci n’existent pas encore au moment de la réception du message.

Sauf application de règles de sécurité particulières, le publisher peut envoyer un message sur le topic de son choix.

Attention aux paramètres régionaux

Bien que le protocole MQTT ne s’embarrasse pas du type des données lors de la transmission de valeurs sur un topic (valeur sans typage, MQTT ne transmet que des chaînes de caractères), la transmission dans le message de données télémétriques requiert une conversion de valeur numérique/décimale en chaîne de caractères.

Éviter les fonctions de conversion faisant intervenir les paramètres régionaux, car cela permet d’éviter les problèmes d’interopérabilité. Par exemple, la conversion d’un nombre en virgule flottante doit idéalement utiliser un point (et non une virgule) comme séparateur décimal et aucun séparateur de milliers (qui est souvent un espace ou une virgule). Cette remarque concerne surtout les applications sur PC et les systèmes embarqués propulsés par un système d’exploitation, les microcontrôleurs ne prenant habituellement pas en charge la gestion des paramètres régionaux.

Le RFC7159 - « The JavaScript Object Notation (JSON) Data Interchange Format » de l’IETF (Internet Engenering Task Force) est une excellente référence et source d’information pour la conversion de données typées. Voir https://tools.ietf.org/html/rfc7159.

6. Les subscribers

Les subscribers sont les clients du broker. Un subscriber souscrit à un (ou plusieurs) topic afin d’être notifié de l’arrivée de nouveaux messages sur le (les) dit(s) topic(s).

Sur un forum, cela correspondrait à une demande de notification (souvent par e-mail) sur un sujet en particulier. L’utilisateur reçoit une nouvelle notification à chaque parution d’un message sur le sujet en question.

Sur le broker MQTT, le mécanisme de souscription utilise une expression de filtrage afin de sélectionner le ou les topics concernés. L’expression de filtrage est très similaire à la constitution d’un topic (et d’une hiérarchie de topics). L’expression de filtrage inclut l’utilisation de caractères jokers permettant la sélection de plusieurs topics en une seule opération.

Une fois l’expression de filtrage utilisée, le subscriber recevra une copie de tous les nouveaux messages reçus par le broker MQTT et répondant au filtre.

Un subscriber peut émettre plusieurs souscriptions.

Un subscriber peut également agir comme publisher.

Sauf règle de sécurité spécifique au broker MQTT utilisé, un subscriber peut souscrire à n’importe quel topic.

7. Le ClientId

Le ClientId est l’acronyme de Client Identifier (identification client). Le ClientId permet d’identifier chacun des clients se connectant sur le broker MQTT, il est par conséquent unique sur celui-ci.

Cette information peut être omise lors d’une utilisation rudimentaire du broker MQTT. Chaque objet/client étant alors considéré comme anonyme, bien que le broker génère un ClientId renvoyé en réponse à la demande de connexion.

Le ClientId revêt par contre d’une tout autre importance lorsqu’il faut maintenir un état du client (ex. : solution professionnelle et stockage en base de données) ou lorsqu’il s’agit d’exploiter un « client persistant » (fonctionnalité standard de MQTT abordée plus loin). Il est alors nécessaire de fournir un ClientId au moment de la connexion sur le broker MQTT. Cela permet à chaque client de s’identifier de façon univoque.

Exemples de ClientId :

La mise en œuvre d’un client persistant (cf. Les clients persistants de ce chapitre) nécessite l’utilisation d’un ClientId communiqué par l’objet.

Les topics en détail

Dans MQTT, les messages contenant les informations sont publiés sur des topics. Les topics (terme anglais signifiant « sujets ») représentent des éléments clés de la communication MQTT. Ils sont organisés sous forme hiérarchique en utilisant une notation proche des chemins d’accès aux fichiers.

Le topic contient un chemin d’accès à l’information et un élément d’identification de la donnée concernée (ex. : Humidite, Statut, Lux). Un peu comme le nom d’un fichier dans un répertoire.

Sur un disque dur, le topic correspond donc au chemin d’accès combiné au nom de fichier qui permet d’accéder à l’information contenue dans ledit fichier. Sur MQTT le topic permet de localiser le message et d’accéder à l’information que celui-ci contient.

Hormis des contraintes de sécurité éventuellement configurées sur le serveur MQTT, l’organisation des topics est à la libre convenance du développeur.

Les topics sont également utilisés par le broker MQTT pour identifier les clients qui doivent être notifiés d’un nouveau message. L’élaboration de la hiérarchie des topics doit être réalisée avec soin et est à respecter à la lettre lors du développement des différents éléments du projet.

Voici quelques exemples de topics identifiant des informations publiées sur un broker MQTT (ne reprend pas les valeurs publiées).

Maison/Rez/Cuisine/Temp 

Maison//Rez/Cuisine/Humidite 

Maison/Rez/Cuisine/Lux 

Maison/Rez/Salon/Temp 

Maison/Rez/Salon/Lux 

Maison/Portail/Statut 

Maison/Jardin/Cabane/Temp 

Maison/Jardin/Cabane/Humidite 

Maison/Jardin/Cabane/Pression 

Maison/Jardin/Temp 

Maison/Jardin/Pression 

Maison/Jardin/Lux

Par exemple, le topic Maison/Rez/Cuisine/Temp permet de clairement identifier un relevé de température (Temp) dans la cuisine. La mention de Rez dans la hiérarchie du topic n’est superflue qu’en apparence. Comme cela sera démontré plus loin, il est possible d’être notifié pour toutes les températures du Rez.

Les exemples ci-dessus mettent en évidence d’autres relevés de températures. Ainsi que d’autres types de relevés comme l’humidité relative (Humidite) ou la luminosité avec précision (Lux).

Les exemples permettent de constater que :

Viennent se greffer quelques règles de création :

1. Contenu du message

a. Le message selon MQTT

Par essence, le message d’un broker MQTT ne devrait contenir qu’une information télémétrique. À comprendre : un seul relevé, une seule information !

Si l’objet doit renvoyer plusieurs informations (ex. : température, pression atmosphérique d’un unique senseur comme le BMP280 de Bosch), alors ces relevés doivent être publiés sur des topics différents comme le prévoit le standard.

maison/jardin/cabane/temp → 28.2 

maison/jardin/cabane/pathm → 1030.7

Cette approche présente l’avantage de ne pas réclamer de traitement complémentaire pour utiliser et exploiter les informations souhaitées. Le message peut donc être traité quelle que soit la puissance de la plateforme (PC ou objet IoT) y ayant souscrit.

Dans le même ordre d’idée, si une unité de mesure correspondant au réglage physique d’un appareil doit être communiquée avec la valeur (par exemple, A pour ampères ou mA pour milliampères), il est recommandé d’utiliser un topic séparé pour communiquer l’unité au broker plutôt que d’inclure cette unité avec la valeur de mesure.

atelier/machinerie/laser/courant → 15.3 

atelier/machinerie/laser/courant-unite → A

b. En marge du standard

Une tendance de plus en plus populaire et exploitée par de nombreuses solutions est d’utiliser le standard JSON pour transmettre de multiples informations structurées dans un seul message. 

maison/jardin/cabane/bmp280 → {"counter": {"valeur": 123},

"pression": {"valeur": 1030.1, "unite": "millibar"},

"temperature": {"valeur": 15.7, "unite": "celsius"}}

Dans l’exemple ci-dessus, les deux relevés sont envoyés en un seul message, incluant également des informations sur les unités employées ainsi qu’un compteur de relevé.

Bien que séduisante, cette approche non standard est jugée contre-productive. En effet, elle alourdit les messages et nécessite un traitement spécifique pour extraire les informations. Possibilité de traitement qui n’est pas forcément à la portée de tous les subscribers.

De même, le stockage des informations dans une base de données requiert un traitement plus lourd dans le cas d’un message structuré (JSON) que dans le cas d’un message simple contenant le relevé.

L’utilisation de messages encodés ou structurés (type JSON, XML ou autre) doit faire l’objet d’une étude attentive avant sa mise en application.

Bien que non standard, cette approche peut se montrer fort pratique comme le démontre l’implémentation du tableau de bord Crouton (http://crouton.mybluemix.net/crouton/gettingStarted).

Crouton est un projet Node.js permettant de visualiser et contrôler des objets IoT à partir d’une solution qui requiert un minimum de configuration. Crouton s’appuie sur un broker MQTT et des messages structurés au format JSON (cf. GitHub de crouton sur https://github.com/edfungus/Crouton).

2. Création de topic et bonnes pratiques

Les points ci-dessous reprennent quelques recommandations utiles pour l’élaboration d’un topic et du contenu des messages :

 

Bien que le protocole MQTT supporte les accents, les espaces et les caractères spéciaux dans les topics, il est préférable d’éviter ces éléments lors de l’élaboration des topics. Ce n’est pas parce qu’une fonctionnalité est disponible qu’il faut forcément l’exploiter. En effet, UTF-8 connaît différents types d’espaces qui ne sont pas forcément égaux entre eux et il est assez commun d’éviter l’utilisation de caractères « étrangers » au cœur d’un développement. Ce qui est vrai pour le télougou (langue d’Inde pratiquée par 75 millions d’habitants) l’est tout autant pour les caractères spécifiques à la langue française.

3. Les topics système

Les topics système débutent souvent par $SYS, et ceux-ci maintiennent des informations propres au fonctionnement interne du broker MQTT. Il n’y a cependant pas d’harmonisation sur le sujet et, de fait, chaque implémentation de broker MQTT offre une hiérarchie de topics plus ou moins différente pour $SYS.

Les topics débutant par un $ ne sont pas traités comme les autres topics. Ils ne sont pas sélectionnés à l’aide du filtre # (le joker multiniveau, voir ci-dessous).

À titre d’exemple, voici quelques topics $SYS issus de la documentation du broker Eclipse® Mosquitto. Cette information est disponible sur : https://github.com/mqtt/mqtt.github.io/wiki/SYS-Topics

 

Lorsqu’un topic système est saisi sur une ligne de commande avec mosquitto_sub, il est nécessaire d’utiliser une séquence d’échappement en précédant le « $ » d’un caractère « \ ». Par exemple : mosquitto_sub -h pythonic.local -t "\$SYS/broker/load/bytes/sent" -v

Topics du projet

Les topics ci-dessous seront utilisés durant l’élaboration du projet.

Le premier niveau, maison, permet d’étendre le projet à d’autres lieux (résidence, travail, dépendances). La localisation utilise deux sous-niveaux (ex. : rez/salon) permettant une découpe de l’information en niveau/étage physique. Les mesures extérieures sont volontairement organisées en suivant cette découpe de deux sous-niveaux (ex. : exterieur/cabane ou exterieur/jardin).

Les topics sont élaborés en minuscule et sans accents.

L’approche respectant strictement quatre niveaux permet de collecter toutes les températures de la maison avec une seule souscription (ex. : maison/+/+/temp).

connect/<ClientId>

Le client envoie son adresse MAC (si disponible) lorsqu’il est mis en service. Le <ClientId> permet d’identifier l’objet qui se connecte (ex. : veranda, salon, cabane, etc) sur le broker MQTT.

maison/exterieur/cabane/pathm

Pression atmosphérique en hectopascal (hPa). Relevé depuis la cabane de Jardin. Un relevé toutes les 20 minutes.

maison/exterieur/cabane/temp

Température dans la cabane en degré Celsius (°C). Un relevé par heure.

maison/exterieur/cabane/lux

Relevé de luminosité en Lux depuis la cabane. Un relevé par heure.

maison/exterieur/jardin/hrel

Humidité relative extérieur en pourcentage. Un relevé par heure.

maison/exterieur/jardin/temp

Température extérieure en degré Celsius (°C). Un relevé par heure.

maison/rez/salon/temp

Température du salon en degré Celsius (°C). Un relevé par heure.

maison/rez/salon/pir

Indicateur de mouvement dans le salon.

Envoi de « MOUV » lors d’une première activation. Répétition de « MOUV » toutes les 15 minutes si une activité régulière est détectée durant ladite période de 15 minutes. Envoi de « NONE » (sans répétition) si aucune activité n’est détectée au bout de 15 min.

maison/rez/veranda/temp

Température de la véranda en degré Celsius (°C). Un relevé par heure.

maison/rez/veranda/ldr

La photo-résistance est utilisée comme indicateur de l’état d’éclairage de la pièce. Retourne les valeurs « NOIR » et « ECLAIRAGE ». Envoi de l’information à chaque changement d’état.

maison/rez/veranda/portefen

« OUVERT » ou « FERME » indique respectivementsi la porte-fenêtre est ouverte ou fermée.

Envoi uniquement lors du changement d’état.

maison/rez/veranda/ldr

Utilisation d’une photorésistance pour détecter l’éclairage de la pièce avec les valeurs « NOIR » et « ECLAIRAGE ». Information vraiment pertinente la nuit.

maison/cave/chaufferie/cmd

Ce topic permet d’envoyer des commandes à l’objet contrôlant la chaufferie. L’impact d’une commande est reporté dans le topic maison/cave/chaufferie/etat. L’objet n’accepte qu’une seule commande par intervalle de 10 secondes.

Les commandes supportées sont les suivantes :

maison/cave/chaufferie/etat

Topic sur lequel l’objet communique ses changements d’état. L’objet communique également son état juste après le démarrage.

Les états communiqués sont les suivants :

maison/cave/chaufferie/temp-eau

Indique la température du circuit d’eau de la chaufferie en degrés Celsius (°C). Un relevé par heure. Un relevé toutes les 10 minutes pendant une heure lors de l’activation/désactivation de la chaudière.

QoS du projet

Une QoS 2 serait excessive compte tenu des points ci-dessus.

Une QoS 1 pourrait convenir étant donné qu’il s’agit principalement d’un projet de surveillance (monitoring), la duplication de messages ne représente donc pas un problème. QoS 1 assurerait l’acheminement (au moins une fois) des messages. Il serait un choix approprié si le projet devait s’étendre au-delà du réseau local (ex. : utilisation d’un broker MQTT en ligne, relevés en provenance d’autres lieux géographiques...).

QoS 0 sera tout à fait convenable pour ce projet d’exploration où les conditions de communications (réseau Wi-Fi local) sont idéales.

Plateforme IoT et QoS max

La plateforme IoT sélectionnée a également un impact sur la QoS maximum du projet.

Le présent projet utilise des ESP8266 sous MicroPython. La documentation MQTT de MicroPython révèle que la qualité de service maximum supportée est 1.

Il n’est donc pas possible d’opter pour une QoS 2 étant donné qu’elle sera systématiquement rétrogradée à 1 à cause des ESP8266.

Sécurité

Bien que l’installation ne sorte pas du cadre du réseau local domestique, il y a plusieurs points de sécurisation à considérer.

La mise en place de l’encryptage SSL/TSL sur le broker MQTT n’est pas une tâche triviale et dépend également du support de SSL/TSL sur les ESP8266. La consultation de forums indique le support logiciel SSL/TSL pour ESP8266 sous MicroPython (sans authentification). Sa mise en œuvre aussi bien côté Raspberry Pi que côté ESP8266 sort du cadre du présent ouvrage.

L’utilisation d’une combinaison login/mot de passe pour accéder au broker MQTT est un minimum raisonnable pour un apprentissage sur le réseau domestique d’autant qu’il est possible de s’appuyer sur le cryptage du réseau Wi-Fi (même si celle-ci n’est pas infaillible). Le renforcement de la sécurité en utilisant TSL/SSL est un plus nécessaire dès lors que le projet transporte des informations sensibles.  

Configurer le login du broker MQTT

1. Modifier la configuration

Les étapes suivantes permettent de configurer le broker MQTT de façon à n’accepter que les connexions authentifiées avec le login « pusr103 » et le mot de passe « 21052017 ». Ces informations sont requises pour toute publication ou souscription sur le broker MQTT Mosquitto.

La première opération consiste à créer un fichier d’authentification pour y stocker les mots de passe du broker MQTT.

sudo mosquitto_passwd -c /etc/mosquitto/passwd pusr103

La commande mosquitto_passwd permet d’ajouter un utilisateur dans le fichier de mot de passe de Mosquitto. Une fois la commande saisie, l’utilitaire demande le mot passe.

Le drapeau -c permet de créer le fichier passwd. Ce dernier sera écrasé s’il existe déjà.

Par la suite, il est possible de lister les utilisateurs en affichant le contenu du fichier à l’aide de la commande more /etc/mosquitto/passwd.

images/02RI15.PNGimages/02RI15.PNG
 

Création d’un utilisateur sur le broker MQTT

Ensuite, le fichier mosquitto.conf est modifié à l’aide de l’utilitaire nano. La modification du fichier de configuration vise à rejeter les connexions anonymes et à utiliser le fichier de mot de passe nouvellement créé.

Saisir la commande suivante :

sudo nano /etc/mosquitto/mosquitto.conf

Et ajouter les lignes suivantes dans le fichier de configuration :

allow_anonymous false 

password_file /etc/mosquitto/passwd

Comme dans la capture ci-dessous :

images/02RI16.PNGimages/02RI16.PNG
 

Modification du fichier mosquitto.conf

 

Les modifications sont enregistrées en utilisant la combinaison de touches [Ctrl] O pour sauver le fichier, suivie du retour clavier pour confirmer le nom du fichier. Pour finir, presser [Ctrl] X pour quitter le programme nano.

Pour finir, redémarrer le broker MQTT afin qu’il utilise la nouvelle configuration.

sudo systemctl stop mosquitto.service 

sudo systemctl start mosquitto.service

2. Tester la configuration

À partir de maintenant, toutes les opérations de publication ou de souscription nécessiteront l’utilisation d’un login et du mot de passe correspondant.

Tester la publication

La commande suivante produit une erreur, car elle ne contient aucune authentification :

mosquitto_pub -h pythonic.local -t "eni-editions/pythonic" -m "message de test"

images/02RI17.PNGimages/02RI17.PNG
 

Connexion non autorisée sur le broker Mosquitto

En ajoutant les paramètres -u pour l’identification utilisateur et -P pour le mot de passe, la commande de publication est autorisée sur le broker.

mosquitto_pub -h pythonic.local -t "eni-editions/pythonic" -m

"message de test" -u pusr103 -P 21052017

Tester la souscription

De même, la souscription avec l’utilitaire mosquitto_sub accepte les mêmes paramètres -u et -P pour authentifier l’utilisateur.

L’exemple ci-dessous reprend la souscription (dans TERM1) et la publication (dans TERM2) sur le broker en utilisant l’authentification définie.

images/02RI18.PNGimages/02RI18.PNG
 

Publication et souscription avec authentification utilisateur

MQTT en Python

La bibliothèque Python a été installée sur le Raspberry Pi en même temps que le broker MQTT Mosquitto. La bibliothèque paho-mqtt, anciennement nommée python-mosquitto, permet d’interagir avec le broker Mosquitto. Le projet Eclipse Paho vise à créer des implémentations open source du protocole MQTT pour différents langages de programmation (C, Python, Arduino, Java, JavaScript, C#, etc.).

À défaut d’une installation MQTT complète, il est possible d’installer uniquement la bibliothèque MQTT Python pour Python 2.7 (encore très utilisée sur Raspberry Pi).

sudo pip install paho-mqtt

1. test-mqtt-client-sub.py

Le script de test test-mqtt-client-sub.py indique comment utiliser la bibliothèque MQTT pour réaliser une souscription sur le broker.

Le script test-mqtt-client-sub.py est disponible sur le référentiel GitHub suivant : https://github.com/mchobby/la-maison-pythonic/tree/master/python/divers

Seul le fichier test-mqtt-client-sub.py doit être transféré sur le Raspberry Pi pour tester la souscription.

01: # coding: utf-8  

02: """ Souscription au topic "demo/#" sur le broker Eclipse Mosquitto.  

03:  

04:     Utilise une authentification login/mot-de-passe sur le broker  

05: """  

06: import paho.mqtt.client as mqtt_client  

07:  

08: # Configuration  

09: MQTT_BROKER = "pythonic.local"  

10: MQTT_PORT   = 1883  

11: KEEP_ALIVE  = 45 # intervale en seconde  

12:  

13: def on_log( client, userdata, level, buf ):  

14:     print( "log: ",buf)  

15:  

16: def on_connect( client, userdata, flags, rc ):  

17:     print( "Connexion: code retour = %d" % rc )  

18:     print( "Connexion: Statut = %s" %  

19:              ("OK" if rc==0 else "échec") )  

20:  

21: def on_message( client, userdata, message ):  

22:     print( "Reception message MQTT..." )  

23:     print( "Topic : %s" % message.topic )  

24:     print( "Data  : %s" % message.payload )  

25:  

26:  

27: client = mqtt_client.Client( client_id="client007" )  

28:  

29: # Assignation des fonctions de rappel  

30: client.on_message = on_message  

31: client.on_connect = on_connect  

32: #client.on_log = on_log  

33:  

34: # Connexion au broker MQTT 

35: client.username_pw_set( username="pusr103",  

36:                         password="21052017" )  

37: client.connect( host=MQTT_BROKER, port=MQTT_PORT, 

38:                 keepalive=KEEP_ALIVE )  

39: client.subscribe( "demo/#" )  

40:  

41: # traitement des messages  

42: client.loop_forever()

 

Il est important de signaler que l’appel de la méthode connect() à la ligne 37 n’est pas bloquant ! Si le client ne peut pas se connecter sur le broker (ex. : mauvais login/mot de passe ou connexion instable), le script passera à l’exécution de la ligne 39. Le client fera cependant différentes tentatives en tâche de fond, ce que démontre l’utilisation de la fonction de rappel on_log().

Ce script peut être testé en le démarrant avec la commande python test-mqtt-client-sub.py (TERM1), puis en envoyant un message sur le topic souscrit à l’aide de la commande (TERM2).

mosquitto_pub -h pythonic.local -t "demo/msg" -m "abc" -u pusr103 -P 21052017

La commande mosquitto_pub dans TERM2 déclenche une publication sur le topic demo/msg, message qui apparaît immédiatement dans TERM1 exécutant le script Python ayant une souscription sur demo/#.

Ce qui produit le résultat suivant :

images/02RI19.PNGimages/02RI19.PNG
 

Test de souscription MQTT en Python.

Utilisation du log

La fonction de rappel on_log() peut s’avérer très utile pour suivre l’évolution des échanges MQTT dans le script.

L’exemple suivant se propose de démontrer l’utilité des logs en modifiant le script test-mqtt-client-sub.py comme suit :

1.

Retirer la mise en commentaire (le caractère « # ») de la ligne 32 pour obtenir client.on_log = on_log.

2.

Modifier le mot de passe à la ligne 35 pour provoquer une erreur de login sur le broker MQTT. Par exemple, utiliser le mot de passe « erreur » à la place de « 21052017 ». La ligne 35 devrait donc ressembler à ceci : client.username_pw_set( username="pusr103", password="erreur" )

Redémarrer le script à l’aide de la commande python test-mqtt-client-sub.py pour voir les différents messages de log s’afficher sur le terminal. Ces messages (sur TERM1) montrent les différentes tentatives de connexion au broker MQTT.

TERM1: python test-mqtt-client-sub.py  

(’log: ’, ’Sending CONNECT (u1, p1, wr0, wq0, wf0, c1, k45) client_id=client007’)  

(’log: ’, "Sending SUBSCRIBE (d0) [(’demo/#’, 0)]")  

(’log: ’, ’Received CONNACK (0, 4)’)  

Connexion: code retour = 4  

Connexion: Statut = échec  

(’log: ’, ’Sending CONNECT (u1, p1, wr0, wq0, wf0, c1, k45) client_id=client007’)  

(’log: ’, ’Received CONNACK (0, 4)’)  

Connexion: code retour = 4  

Connexion: Statut = échec  

(’log: ’, ’Sending CONNECT (u1, p1, wr0, wq0, wf0, c1, k45) client_id=client007’)  

(’log: ’, ’Received CONNACK (0, 4)’)  

Connexion: code retour = 4  

Connexion: Statut = échec

Un second essai, en corrigeant le mot de passe, montre les différents messages échangés avec le broker. Y compris la réception d’un message suite à la souscription demo/#.

TERM1: python test-mqtt-client-sub.py  

(’log: ’, ’Sending CONNECT (u1, p1, wr0, wq0, wf0, c1, k45) client_id=client007’)  

(’log: ’, "Sending SUBSCRIBE (d0) [(’demo/#’, 0)]")  

(’log: ’, ’Received CONNACK (0, 0)’)  

Connexion: code retour = 0  

Connexion: Statut = OK  

(’log: ’, ’Received SUBACK’)  

(’log: ’, ’Sending PINGREQ’)  

(’log: ’, ’Received PINGRESP’)  

(’log: ’, "Received PUBLISH (d0, q0, r0, m0), ’demo/msg’, ...  (3 bytes)")  

Reception message MQTT...  

Topic : demo/msg  

Data  : abc

Il est possible d’y voir la réception d’un message (PUBLISH) débouchant sur l’appel de on_message(). Les échanges PINGREQ/PINGRESP utilisés pour maintenir la connexion entre le client MQTT et le broker sont également visibles.

2. test-mqtt-client-pub.py

Le script de test test-mqtt-client-pub.py effectue des publications sur le broker MQTT.

Le script test-mqtt-client-pub.py est disponible sur le référentiel GitHub suivant : https://github.com/mchobby/la-maison-pythonic/tree/master/python/divers

Seul le fichier test-mqtt-client-pub.py doit être transféré sur le Raspberry Pi pour tester la publication.

01: # coding: utf-8  

02: """ Publication de messages sur le topic "demo/machin-chose"  

03:     du broker Eclipse Mosquitto.  

04:     Utilise une authentification login/mot-de-passe sur  

        le broker  

05: """  

06: import paho.mqtt.client as mqtt_client  

07: from time import sleep  

08:  

09: # Configuration  

10: MQTT_BROKER = "pythonic.local"  

11: MQTT_PORT   = 1883  

12: KEEP_ALIVE  = 45 # interval en seconde  

13:  

14: def on_log( client, userdata, level, buf ):  

15:     print( "log: ",buf)  

16:  

17: client = mqtt_client.Client( client_id="client007" )  

18:  

19: # Assignation des fonctions de rappel  

20: #client.on_log = on_log  

21:  

22: # Connexion broker  

23: client.username_pw_set( username="pusr103", 

                            password="21052017" )  

24: client.connect( host=MQTT_BROKER, port=MQTT_PORT, 

                    keepalive=KEEP_ALIVE )  

25:  

26: # envoi des messages  

27: for i in range(4):  

28:     print( "Publication iteration %s" % i )  

29:     r = client.publish( "demo/machin-chose", "message %s"%i )  

30:     print( "  envoyé" if r[0] == 0 else "  echec" )  

31:     sleep( 1 )

Le script peut être testé en saisissant la commande suivante dans un terminal (TERM2).

mosquitto_sub -h pythonic.local -t "demo/#" -u pusr103 -P 21052017 -v

Puis, en démarrant le script Python à l’aide de la commande python test-mqtt-client-pub.py (TERM1).

Ce qui produit le résultat suivant :

images/02RI20.PNGimages/02RI20.PNG
 

Test de publication en Python sur un broker MQTT

3. Documentation complémentaire

La bibliothèque MQTT client du projet Eclipse Paho utilisée dans ces exemples est documentée sur le serveur GitHub paho.mqtt.python. Ce serveur reprend une description de l’API et des exemples en Python.

https://github.com/eclipse/paho.mqtt.python

MQTT en MicroPython

L’utilisation du broker MQTT sous MicroPython nécessite la mise en œuvre d’une plateforme MicroPython. Ce point est abordé dans le chapitre relatif à l’ESP8266 (cf. ESP8266 sous MicroPython - MQTT sous ESP8266).

Présentation de l’ESP8266

L’ESP8266 est un SoC (System on Chip, Système sur une puce) d’Espressif Systems qui intègre un processeur 32 bits de Tensilica Xtensa LX106 cadencé à 80 MHz, une mémoire RAM de 96 ko, une interface radio 2,4 GHz et une pile TCP/IP complète.

images/03RI01.pngimages/03RI01.png
 

La puce ESP8266

Le Xtensa LX106 dispose de deux cœurs. L’un est dévolu à la gestion de l’interface radio (le Wi-Fi) tandis que le second reste disponible pour les applications utilisateurs. L’ESP8266 peut ainsi proposer à l’application utilisateur une unité de contrôle indépendante avec GPIO (broches d’entrée/sortie), bus série, I2C, SPI sans que celle-ci ne perturbe le traitement des transmissions radio (et inversement). Grâce à son très faible coût, l’ESP8266 permet de réaliser des plateformes Wi-Fi IEEE 802.11 b/g/n autonomes avec seulement quelques composants supplémentaires. L’ESP8266 s’est rapidement trouvé au cœur de nombreuses plateformes et projets permettant de développer des objets connectés.

L’ESP8266 n’étant pas facile à manipuler en l’état, il est également vendu sous forme de modules. Il existe une vingtaine de modules ESP8266 différents, dont l’ESP-12 et ses descendants directs.

images/03RI02.pngimages/03RI02.png
 

Module ESP8266 ESP-12

L’ESP-12 est un module ESP8266 avec antenne intégrée disposant d’une déclaration de conformité FCC et CE. Ces certifications garantissent que les interférences électromagnétiques du module ESP-12 restent en dessous des limites imposées par la FCC (Federal Communication Commission) et la Communauté Européenne.

1. Les possibilités offertes par l’ESP8266

L’ESP8266 dispose de ressources limitées classant ce dernier dans le domaine des microcontrôleurs. Il est donc possible d’y brancher des senseurs pour collecter des données et d’utiliser les broches d’entrée/sortie pour agir sur l’environnement immédiat (contrôler une pompe, allumer l’éclairage, activer un relais, etc.).

Comparé au célèbre Arduino Uno (le microcontrôleur de référence dans le monde des makers), les ressources d’un ESP8266 sont néanmoins bien supérieures. À titre de comparaison : la mémoire flash peut atteindre 4 Mo (128 fois plus qu’un UNO), la SRAM disponible est de 64 ko (32 fois plus qu’un UNO), la vitesse d’horloge est de 80 MHz (5 fois plus rapide qu’un UNO). C’est sans compter sur le support Wi-Fi offert par l’ESP8266.

Le support Wi-Fi de l’ESP8266 offre des fonctionnalités et des perspectives intéressantes :

2. Les plateformes ESP8266 populaires

Il existe bon nombre de plateformes de développement ESP8266. La liste ci-dessous reprend les plateformes les plus populaires :

Toutes ces plateformes utilisent le module ESP-12 et bénéficient donc des certifications FCC et CE.

Le Feather Huzzah ESP8266 d’Adafruit Industries est une carte de la famille Feather. Feather est une gamme de cartes de développement standardisées, compactes et légères spécialement conçues pour les projets microcontrôleurs embarqués. Le Feather Huzza ESP8266 dispose de fonctionnalités intéressantes qui facilitent le prototypage et le développement de projets ESP8266 (cf. Présentation de l’ESP8266 - Feather Huzzah ESP8266 en détail dans ce chapitre).

images/03RI03.pngimages/03RI03.png
 

Feather Huzzah ESP8266

 

Le Feather Huzzah ESP8266 étant l’une des plateformes de référence utilisée durant le développement de MicroPython sur ESP8266, elle sera également la plateforme utilisée pour la création des objets de cet ouvrage.

Le Huzzah ESP8266 d’Adafruit Industries est une carte breakout pour ESP8266. Cette dernière propose un breakout des signaux de l’ESP8266 et le minimum de composants nécessaires pour utiliser un ESP8266. La carte dispose d’empattement 2,54 mm compatible avec les plaques de prototypage sans soudure, d’un régulateur de tension 3,3 V et des boutons « Reset » et « GPIO0 » requis pour l’activation du bootloader. Le port série est accessible sur le connecteur FTDI visible sur la droite de la carte. Ce connecteur permet de brancher un convertisseur USB-Série FTDI ou un câble console autorisant ainsi l’envoi d’un nouveau firmware depuis un ordinateur.

images/03RI04.pngimages/03RI04.png
 

Huzzah ESP8266 et convertisseur USB-Série FTDI

Le NodeMCU est également une carte ESP8266 existant sous de nombreuses variantes (facteur de forme différent, avec module ESP-12 certifié ou non certifié, avec différents convertisseurs USB-Série). Ces cartes de développement sont principalement utilisées avec le firmware par défaut NodeMCU qui permet de programmer le module avec des scripts Lua. Comme le Huzzah ESP8266, la carte dispose d’un convertisseur USB-Série, d’un régulateur de tension et des boutons « Reset » et « Flash » (GPIO0) requis pour l’activation du bootloader.

images/03RI05.pngimages/03RI05.png
 

Une des nombreuses variantes de NodeMCU

Le Wemos D1 Mini est une autre carte ESP8266 très populaire (surtout dans l’hexagone) disposant de 4 Mo de mémoire Flash. Tout comme le NodeMCU, Wemos existe sous différentes variantes, avec module ESP8266 certifié ou sans module certifié. Malgré les nombreux fabricants de Wemos, le facteur de forme des Wemos reste identique, ce qui est un avantage certain étant donné qu’il existe des cartes d’extensions pour Wemos.

images/p109.pngimages/p109.png
 

Wemos D1 mini (à base d’ESP-8266EX, existe également avec des modules ESP-12 certifiés)

Le Wemos D1 mini PRO est une variante intéressante du Wemos. Celui-ci offre une antenne céramique et un connecteur µFl pour brancher une antenne externe. L’autre intérêt du Wemos D1 mini PRO c’est qu’il est équipé de 16 Mo, un espace très utile pour stocker des ressources. La version PRO telle que présentée ici doit faire l’objet d’un processus de certification.

images/03RI111.pngimages/03RI111.png
 

Wemos D1 mini PRO (à base d’ESP-8266EX)

3. Programmer un ESP8266

Plusieurs approches sont possibles pour programmer un ESP8266. Les méthodes de programmation les plus utilisées sont :

Les firmwares NodeMCU, MicroPython et Espruino interprètent le contenu de scripts directement sur l’ESP8266. Cette approche présente une certaine souplesse, car elle permet d’adapter et de corriger facilement les scripts avec un éditeur de texte. Ces scripts sont ensuite copiés sur l’ESP8266 où ils seront interprétés. Cette souplesse a cependant un coût, l’exécution d’un script est plus lente qu’un programme natif. NodeMCU est le firmware par défaut des modules ESP-12 qui permet d’interpréter les scripts écrits en Lua. L’utilisation d’un autre langage de script tel que MicroPython nécessite le téléversement d’un nouveau firmware sur le module.

À l’opposé des scripts, Arduino IDE permet de produire des programmes natifs pour ESP8266. Arduino IDE utilise un compilateur pour produire un fichier binaire qui sera ensuite téléversé et exécuté par l’ESP8266. Les programmes natifs seront plus rapides que leurs scripts équivalents. Cela implique cependant une phase de compilation qui alourdit le cycle de développement et nécessite l’utilisation d’un matériel informatique adapté (plus puissant).  

 

Dans le cadre de cet ouvrage, les modules ESP8266 seront programmés avec des scripts Python. Les modules seront mis à jour pour recevoir le firmware MicroPython pour ESP8266.

4. Feather Huzzah ESP8266 en détail

Parmi les plateformes ESP8266 disponibles sur le marché, le Feather Huzzah ESP8266 d’Adafruit Industries est une plateforme de développement disposant de nombreuses qualités, à prix abordable et disponible dans le monde entier.

Le Feather Huzza ESP8266 embarque le module ESP8266 ESP-12 certifié FCC et CE.  

images/03RI10.pngimages/03RI10.png
 

Feather Huzzah ESP8266 (vue de face et vue arrière)

Feather est une gamme de cartes de développement standardisées, compactes et légères spécialement conçues pour les projets microcontrôleurs embarqués. Cette gamme propose des cartes de développement pour plusieurs types de microcontrôleurs ainsi que de nombreuses cartes d’extension. N’hésitez pas à consulter la gamme Feather chez MC Hobby (https://shop.mchobby.be/87-feather) et chez Adafruit (https://www.adafruit.com/category/817) pour de plus amples informations.

La carte Feather Huzzah ESP8266 dispose d’une interface USB-Série (plus exactement USB vers UART) permettant à un ordinateur de communiquer directement avec le port série de l’ESP8266 par l’intermédiaire d’un port USB. L’interface USB-Série du Feather dispose d’une électronique d’auto-réinitialisation (auto-reset) de la plateforme qui réinitialise le module ESP8266 lorsqu’un nouveau firmware doit y être téléversé.

La carte dispose également d’un connecteur pour accumulateur Lithium Polymère 3,7 V (optionnel) et du circuit de recharge correspondant. Le circuit d’alimentation du Feather bascule automatique entre les différentes sources d’alimentation de la carte (USB ou Accumulateur LiPo). L’accumulateur est automatiquement rechargé lorsque le Feather est branché sur une source d’alimentation USB.

Détails techniques de la plateforme :

5. Brochage du Feather Huzzah ESP8266

 

Le Feather Huzzah ESP8266, comme l’ESP8266, utilise des signaux en logique 3,3 V. Sauf mention contraire, les broches GPIO ne sont pas tolérantes au 5 V. De même, l’entrée analogique ADC ne supporte pas une tension supérieure à 1,0 V.

a. Alimentation

images/03RI11.pngimages/03RI11.png
 

Alimentation d’un Feather Huzzah ESP8266

La carte Feather peut être alimentée depuis le connecteur micro USB (en 5 V) ou depuis un accumulateur Lithium Polymère ou Lithium Ion optionnel branché sur le connecteur JST (en noir, sur la gauche de la broche BAT). Le circuit d’alimentation du Feather passe automatiquement d’une source d’alimentation à l’autre. L’accumulateur est automatiquement mis en charge (100 mA) lorsqu’une source d’alimentation 5 V est branchée sur le connecteur micro USB. La charge de l’accumulateur est indiquée par la LED CHG.

Le Feather utilise un régulateur à faible chute de tension (Low Drop Out) pour réguler la tension à 3,3 V, tension de fonctionnement de l’ESP8266. Ce régulateur est capable d’offrir 500 mA en courant de pointe. Étant donné que l’ESP8266 peut occasionnellement consommer jusqu’à 250-300 mA, il est recommandé de ne pas ponctionner plus de 250 mA supplémentaires au risque de faire surchauffer le régulateur de tension.

La liste ci-dessous reprend le détail des différentes broches relatives au circuit d’alimentation :

b. Port série

images/03RI14.pngimages/03RI14.png
 

Port série du Feather Huzzah ESP8266

Le port série (plus précisément l’UART, Universal Asynchronous Receiver Transmitter) de l’ESP8266 permet à celui-ci de communiquer avec un périphérique externe pour échanger des informations. Le port série est, entre autres, utilisé pour envoyer un nouveau firmware et dialoguer avec le firmware en cours de fonctionnement sur l’ESP8266 (ex. : transfert de fichiers, messages de débogage, interaction en ligne de commande, etc.).

Sur un Feather Huzzah ESP8266, l’UART est connecté sur un convertisseur USB-Série (CP2104 de Silicon Labs), ce qui permet de dialoguer directement avec l’ESP8266 par l’intermédiaire du port micro USB présent sur la carte Feather.

 

Le convertisseur CP2104 est très bien supporté par les systèmes Linux où l’installation du pilote spécifique n’est généralement pas requise. Pour les autres systèmes d’exploitation, Silicon Labs propose une page téléchargement pour les pilotes des convertisseurs CP210x (https://www.silabs.com/products/development-tools/software/usb-to-uart-bridge-vcp-drivers).

c. Broches d’entrée/sortie

Le module Feather dispose de plusieurs broches GPIO (General Purpose Input Output, Entrée Sortie pour Usage Général) en logique à 3,3 V.

Bien que certaines d’entre elles soient associées aux bus I2C et SPI, il est également possible d’utiliser les broches GPIO #0,2,4,5,12,13,14,15,16 comme de simples entrées/sorties.

images/03RI12.pngimages/03RI12.png
 

Broches GPIO et bus du Feather Huzzah ESP8266

Le courant maximum par broche est de 12 mA.

Comme sur bon nombre de microcontrôleurs, il est possible d’activer une résistance interne de rappel à +3,3 V (communément appelée « résistance pull-up ») sur presque tous les GPIO.

Lorsqu’une broche est utilisée en entrée avec la résistance pull-up activée, la tension de cette broche est ramenée au niveau logique haut (+3,3 V) à moins qu’un signal ne soit appliqué sur celle-ci.

Toutes les broches, à l’exception de GPIO #16, peuvent générer un signal PWM (Pulse Width Modulation, modulation de longueur d’impulsion).

Les broches suivantes disposent également d’une fonctionnalité spéciale :

 

Le bus I2C est un bus de données populaire pour lequel il est facile de trouver des composants et des senseurs déjà pré-assemblés sur des cartes breakouts. À moins d’une absolue nécessité de l’utilisation du GPIO, il est recommandé de garder ces broches libres aussi longtemps que possible. En cas d’absolue nécessité, un composant comme le MCP23017 (I2C GPIO Expander) permet d’ajouter 16 entrées/sorties supplémentaires sans condamner le bus I2C.  

d. Entrée analogique

images/03RI13.pngimages/03RI13.png
 

Entrée analogique du Feather Huzzah ESP8266

L’ESP8266 dispose d’une entrée analogique accessible via la broche ADC. Le convertisseur analogique/numérique offre une résolution de 10 bits. La lecture de la broche ADC retourne une valeur entre 0 et 1024.

La tension maximale tolérée par le convertisseur analogique/numérique est de 1,0 V. Dépasser cette tension risque de détruire votre ESP8266.

Pour lire une tension supérieure à 1 volt, il est nécessaire de recourir à un pont diviseur de tension (ou similaire) pour ramener celle-ci en dessous de 1,0 V.

e. Les autres broches

images/03RI15.pngimages/03RI15.png
 

Charger le firmware MicroPython

Les modules ESP8266 ESP-12 sont chargés par défaut avec le firmware NodeMCU permettant de programmer le module avec des scripts Lua.

Le développement des objets étant réalisé avec Python pour microcontrôleur, cette section explique comment « reflasher » l’ESP8266 pour y téléverser le firmware MicroPython.

Étant donné que le projet utilise également un Raspberry Pi sous Raspbian, nous disposons donc d’une machine Linux pour reflasher les ESP8266. Le Raspberry Pi doit disposer d’une connexion Internet, car il sera nécessaire de télécharger des fichiers binaires et d’installer des paquets logiciels.

 

L’utilisation d’une machine Linux (comme le Raspberry Pi) présente un réel avantage pour téléverser le firmware MicroPython sur les cartes ESP8266.

Toutes les opérations seront conduites en ligne de commande depuis le Raspberry Pi. Le terminal sera donc notre seul outil de travail, il est accessible :

1. Identifier le firmware MicroPython

Le firmware MicroPython à téléverser sur l’ESP8266 est un fichier binaire. Ce dernier est disponible au téléchargement depuis MicroPython.org.

Le firmware MicroPython pour ESP8266 évolue régulièrement, il est donc nécessaire d’identifier la version et le lien de téléchargement de celui-ci.

Une visite à l’adresse http://www.micropython.org/download#esp8266 avec un navigateur Internet indique que le dernier firmware disponible pour ESP8266 est la version 1.9.1. Maintenir la souris au-dessus du lien permet de connaître l’URL à utiliser pour télécharger le firmware (visible en bas à gauche dans le cas de Firefox).

images/03RI20.pngimages/03RI20.png
 

Identification du firmware MicroPython (et lien de téléchargement)

Le lien de téléchargement du firmware au moment de la rédaction de ces lignes est : http://micropython.org/resources/firmware/esp8266-20170612-v1.9.1.bin

2. Préparatifs

L’outil Python esptool doit être installé sur le Raspberry Pi pour pouvoir téléverser le nouveau firmware MicroPython vers l’ESP8266.

 Saisissez la commande suivante depuis un terminal :

sudo pip install esptool

Qui affiche le résultat suivant une fois l’utilitaire installé :

images/03RI21.pngimages/03RI21.png
 

Installation de l’utilitaire esptool

 

Si l’installation d’esptool débouche sur un message d’erreur « TypeError: unsupported operant type(s) for -=: », il est possible de contourner le problème en installant les différents composants séparément :

sudo pip install ecdsa

sudo pip install pyaes

sudo pip install esptool

 Branchez l’ESP8266 sur un port USB du Raspberry Pi.

images/03RI22.pngimages/03RI22.png
 

Brancher l’ESP8266 sur le port USB du Raspberry Pi

 Puis saisissez la commande dmesg dans le terminal pour identifier le périphérique série associé au convertisseur USB-Série de la carte Feather.

La commande dmesg affiche les messages de débogage du noyau dans lesquels apparaissent les messages concernant la détection d’un nouveau périphérique.

images/03RI23.pngimages/03RI23.png
 

Identification du périphérique série associé à l’ESP8266

Les messages rapportent la détection du convertisseur USB-Série et, sur la dernière ligne, son association avec le périphérique ttyUSB0.

L’ESP8266 est donc accessible via :

/dev/ttyUSB0

3. Reflasher l’ESP8266

La première étape consiste à télécharger le firmware MicroPython sur le Raspberry Pi.

Les quelques lignes suivantes exécutées dans le terminal téléchargent le firmware (identifié ci-avant) sur le Raspberry Pi :

cd /tmp 

mkdir upy 

cd upy 

wget http://micropython.org/resources/firmware/esp8266-20170612-v1.9.1.bin

Ce qui charge le fichier esp8266-20170612-v1.9.1.bin dans le répertoire /tmp/upy/.

images/03RI24.pngimages/03RI24.png
 

Téléchargement du firmware MicroPython

La deuxième étape efface la mémoire flash du module ESP8266 en utilisant la commande suivante :

esptool.py --port /dev/ttyUSB0 erase_flash

Le paramètre --port reprend le chemin d’accès vers le périphérique /dev/ttyUSB0 associé au port série de l’ESP8266.

L’utilitaire esptool affiche différents messages de progression et termine l’opération par une réinitialisation de l’ESP8266 (voir le message « Hard resetting... »).

images/03RI25.pngimages/03RI25.png
 

Effacer la mémoire flash de l’ESP8266

La LED bleue présente sur l’ESP8266 (près de l’antenne), présente une activité en début d’opération. La LED rouge branchée sur le GPIO #0 s’allume faiblement durant (et après) toute l’opération d’effacement.

La fonctionnalité d’auto-réinitialisation du Feather Huzzah ESP8266 nous épargne toute manipulation supplémentaire.

Pour terminer, la commande suivante téléversera le firmware MicroPython sur l’ESP8266. Il s’agit du fichier esp8266-20170612-v1.9.1.bin précédemment téléchargé depuis micropython.org.

esptool.py --port /dev/ttyUSB0 --baud 115200 write_flash 

--flash_size=detect -fm dio 0 esp8266-20170612-v1.9.1.bin

Le débit est volontairement réduit à 115200 bauds afin de préserver la stabilité des communications via la pile USB du Raspberry Pi.

images/03RI26.pngimages/03RI26.png
 

Téléversement du firmware MicroPython sur l’ESP8266

La LED rouge branchée sur le GPIO #0 reste allumée durant toute l’opération. La LED bleue présente sur le module ESP8266 clignote régulièrement durant l’opération.

Une fois l’opération terminée, l’utilitaire esptool affiche le message « Hard resetting... » avant de réinitialiser l’ESP8266. Une fois le module réinitialisé, les deux LED rouge et bleue seront éteintes.

MicroPython est maintenant chargé et fonctionnel !

Flasher un NodeMCU

Dans le cas d’une plateforme comme NodeMCU, il est nécessaire d’activer manuellement le bootloader de l’ESP8266 en maintenant le bouton GPIO0 enfoncé (ou en mettant la broche GPIO #0 à la masse) pendant que le bouton « Reset » est pressé, puis relâché. Enfin, il faut relâcher le bouton du GPIO #0.

La commande pour flasher l’ESP est identique à celle du Feather.

Flasher un Wemos D1 mini

Le Wemos D1 mini dispose également d’un mécanisme d’auto-reset qui active le bootloader de l’ESP8266.

La procédure pour flasher un Wemos D1 mini est identique au Feather ESP8266.

Flasher un Wemos D1 Mini Pro

Le Wemos D1 Mini Pro dispose de 16 Mo de mémoire Flash. Plusieurs tests ont été nécessaires pour flasher correctement MicroPython sur l’ESP8266. Les présentes lignes ayant été ajoutées tardivement dans l’ouvrage, c’est la version 1.9.4 qui a été flashée sur l’ESP8266.

La commande utilisée pour flasher l’ESP8266 doit être adaptée afin de tenir compte de la mémoire Flash utilisée. À noter que, contre toute attente, c’est le paramètre mentionnant une mémoire Flash de 32 Mo qui permet de flasher correctement l’ESP8266.

esptool.py --port /dev/ttyUSB0 --baud 115200 write_flash -fm dio 

-fs=32m 0 esp8266-20180511-v1.9.4.bin  

Prise de contrôle

1. Communiquer avec MicroPython

MicroPython n’est pas seulement une implémentation de Python 3, il offre au microcontrôleur des services plus proches d’un système d’exploitation. MicroPython prend en charge le stockage et le transfert de fichiers dans la mémoire Flash, le support d’une ligne de commande interactive (dite REPL), le support de la connexion Wi-Fi/Ethernet et de services tels que Telnet, FTP, WebREPL. 

images/03RI30.pngimages/03RI30.png
 

La disponibilité des services dépend principalement de la plateforme utilisée (ESP8266, PyBoard, MicroBit, WiPy, SiPy, LoPy, Metro Express, Feather M0 Express, Circuit Playground Express). Certaines plateformes MicroPython branchées en USB sur un ordinateur sont capables d’exposer un système de fichier. C’est le cas de la carte Pyboard (plateforme MicroPython originelle) détectée comme un lecteur Flash USB sur lequel il est possible d’éditer directement les scripts Python à l’aide d’un éditeur de texte.

D’autres plateformes, comme l’ESP8266, ne disposent pas des ressources matérielles adéquates pour exposer un système de fichier USB (voire un service FTP natif). Il est néanmoins possible de transférer des scripts/fichiers sur le système de fichiers MicroPython par l’intermédiaire de l’interface série (USB-Série) ou via le service WebREPL (qu’il faudra activer via l’interface USB-Série).

2. Communiquer avec un ESP8266 sous MicroPython

Bien que l’ESP8266 soit un microcontrôleur disposant de deux cœurs et d’une interface Wi-Fi, il souffre de quelques limitations matérielles contraignantes. À titre d’exemple, l’ESP8266 ne dispose pas d’un support USB natif, ce qui empêche MicroPython d’exposer une partie de la mémoire flash interne comme un système de fichiers. MicroPython sur ESP8266 met néanmoins en place d’autres stratégies de communication.

Après le téléversement du firmware MicroPython sur l’ESP8266, le seul canal de communication disponible est l’interface série (UART). Cette interface série est facilement accessible sur un Feather Huzzah ESP8266 grâce au convertisseur USB-Série qu’il embarque.  

Les points suivants guident le lecteur dans les différentes stratégies de communication à disposition.

3. REPL : l’invite de commandes MicroPython

MicroPython propose un service en ligne de commande appelé REPL (Read-Eval-Print-Loop, boucle de lecture, évaluation et affichage). REPL agit comme un terminal Python. Il permet de saisir des commandes Python et d’afficher le résultat de celles-ci.

REPL est pratique pour tester des instructions et les modules Python et offrira d’inestimables services durant les séances de débogage.

REPL est accessible via l’interface série et permet, à l’aide d’utilitaires appropriés, de transférer des fichiers sur l’ESP8266, de configurer la connexion Wi-Fi (via le fichier boot.py) et d’activer WebREPL (REPL via une interface web).

En installant l’utilitaire picocom (un logiciel terminal) sur le Raspberry Pi, il sera possible d’initier une session REPL via la connexion USB-Série (sur /dev/ttyUSB0).

sudo apt-get install picocom

images/03RI31.pngimages/03RI31.png
 

Installation de picocom

Une fois installé, picocom permet d’ouvrir une session REPL à l’aide de la commande suivante :

sudo picocom /dev/ttyUSB0 -b115200

Une fois la session REPL ouverte, le firmware MicroPython affiche l’invite de commandes REPL « >>> ».

images/03RI32.pngimages/03RI32.png
 

Ouverture de la session REPL sur le Feather Huzzah ESP8266

Il est maintenant possible de saisir des instructions Python et d’inspecter les résultats.

 

Il est parfois nécessaire de presser plusieurs fois la touche retour clavier pour obtenir l’invite de commandes « >>> ».

La capture d’écran ci-dessous présente plusieurs interactions avec REPL.

images/03RI33.pngimages/03RI33.png
 

Exemple d’interaction avec REPL MicroPython

Les éléments suivants sont notables :

REPL propose également un mode spécial appelé « paste mode » (mode coller) permettant de réaliser un copier/coller d’un bloc de code de plusieurs lignes et déjà indenté. Le « paste mode » est activé en pressant [Ctrl] E. L’interpréteur affiche alors l’invite de commandes ===. Après avoir collé le bloc de code dans l’interpréteur, la pression de [Ctrl] D fait repasser REPL en mode normal (>>>) et provoque l’interprétation du code qui a été collé en « paste mode ».  

 

Pour quitter picocom, il faut presser la combinaison de touches [Ctrl] A suivie de [Ctrl] X.

Si Picocom se montre très utile pour les manipulations REPL, ce seul outil n’est pas suffisant pour conduire des développements. Les outils RShell et Ampy offrent des fonctionnalités avancées comme le transfert de fichiers.  

4. RShell

RShell est un utilitaire Python écrit par Dave Hylands. RShell propose une interface utilisateur similaire au shell Linux et accepte des commandes de type shell (ls, cp, cd, cat, mkdir...) permettant de transférer des fichiers depuis/vers une carte MicroPython, d’afficher et éditer le contenu de fichiers, assurant la prise en charge des sessions REPL (présenté ci-avant).

RShell est capable d’exploiter les connexions séries et/ou Telnet pour communiquer avec une ou plusieurs cartes MicroPython.

Bien qu’il existe d’autres utilitaires, dont Ampy présenté plus loin, la maîtrise d’un outil comme RShell apporte de réels avantages.

RShell utilise Python 3 et s’installe à l’aide de l’utilitaire pip3. Saisissez la commande suivante sur le Raspberry Pi pour installer RShell.

sudo pip3 install rshell

images/03RI34.pngimages/03RI34.png
 

Installation de RShell

RShell accepte de nombreux paramètres en ligne de commande, les plus importants sont :

rshell --port nom_port --baud debit --buffer-size taille_buffer --editor editeur

La commande suivante, saisie dans le terminal, démarre RShell en enregistrant la carte MicroPython présente sur le périphérique /dev/ttyUSB0.

rshell --port /dev/ttyUSB0 --baud 115200 --buffer-size 128 --editor nano

 

Le paramètre buffer-size (fixé à 128 octets) est capital lorsque RShell est utilisé avec des ESP8266 sous MicroPython. La valeur par défaut de ce paramètre (512 octets) provoque un écrasement de la mémoire Flash de l’ESP8266, ce qui détruit le système de fichiers MicroPython. La carte n’étant plus utilisable en l’état, il sera nécessaire de reflasher MicroPython sur l’ESP8266 pour retrouver un système fonctionnel.

Une fois RShell démarré, l’invite de commandes est modifiée et indique le répertoire courant (soit /home/pi dans le cas présent).

images/03RI35.pngimages/03RI35.png
 

Ligne de commande RShell

RShell utilise une notation particulière du système de fichiers afin de distinguer les fichiers locaux des fichiers stockés sur la plateforme MicroPython. Tous les chemins débutants par /pyboard feront référence à la carte MicroPython (l’ESP8266 dans le cas présent). Plusieurs cartes pouvant être branchées en même temps, RShell proposera ensuite /pyboard1, /pyboard2, etc.

RShell propose des commandes shell Linux standard comme ls, cat, cd, cp, edit, rm, mkdir, etc. et quelques autres commandes auxquelles viennent s’ajouter des commandes propres à RShell. À titre d’exemple, les commandes repl, connect et boards permettent respectivement de démarrer une session REPL, d’ajouter/enregistrer une nouvelle carte MicroPython et de lister les cartes actuellement accessibles.

La syntaxe de la commande RShell et des commandes qu’il supporte est documentée :

Les commandes RShell suivantes affichent la liste des fichiers présents sur l’ESP8266 puis le contenu du fichier boot.py présent sur la carte ESP8266 :

ls /pyboard 

cat /pyboard/boot.py

images/03RI36.pngimages/03RI36.png
 

Afficher le contenu d’un fichier stocké sur l’ESP8266

 

Presser la combinaison [Ctrl] D pour quitter RShell.

La carte Feather ESP8266 est équipée d’une LED rouge raccordée sur le GPIO 0. Cette section du tutoriel se propose de poursuivre la découverte de RShell en manipulant cette LED dont logique de contrôle se trouve inversée.

images/03RI41b.pngimages/03RI41b.png
 

Schéma type du raccordement de la LED rouge branchée sur le GPIO 0

Voici les différents cas de figure de contrôle de la LED.

Voici qui termine ce petit aparté concernant le GPIO 0 et la LED rouge.

Les commandes suivantes, exécutées depuis RShell, permettent de créer le fichier test.py et d’en éditer le contenu avec l’éditeur de texte Nano. Le ! indique à RShell que la commande doit être exécutée directement par le shell Linux. Le fichier test.py est stocké dans le répertoire /home/pi du Raspberry Pi.

!touch test.py 

!nano test.py

images/03RI37.pngimages/03RI37.png
 

Création du fichier test.py depuis RShell

 Saisissez le code Python suivant dans l’éditeur Nano.

from machine import Pin 

p = Pin(0, Pin.OUT ) # Broche attachée a la LED rouge 

p.value( 0 ) # Allume la led Rouge / logique inversée

images/03RI38.pngimages/03RI38.png
 

Édition du contenu du fichier test.py

 

Utiliser la combinaison [Ctrl] O pour sauver le contenu du fichier puis [Ctrl] X pour quitter l’éditeur Nano (et revenir dans la session RShell).

Le fichier test.py est maintenant disponible sur le système de fichiers du Raspberry Pi. La commande cp permet de faire une copie de ce fichier sur l’ESP8266. Les commandes ls et cat permettent ensuite de vérifier que le transfert s’est déroulé correctement.

cp test.py /pyboard 

ls /pyboard 

cat /pyboard/test.py

images/03RI39.pngimages/03RI39.png
 

Transfert d’un fichier vers la carte ESP8266

La commande cp test.py /pyboard copie le fichier test.py disponible dans le répertoire courant (/home/pi en l’occurrence) sur l’ESP8266 (identifié par le /pyboard) et plus précisément dans le répertoire racine étant donné qu’aucun chemin n’est précisé. La commande cp test.py /pyboard/test.py aurait produit le même résultat.

La commande ls /pyboard affiche les fichiers disponibles sur la carte ESP8266. Le résultat de la commande affiche le fichier test.py nouvellement transféré sur l’ESP8266.

Pour finir, la commande cat /pyboard/test.py affiche le contenu du fichier tel qu’il est stocké sur la carte.

REPL sous RShell

Une des fonctionnalités intéressantes de RSHell est de pouvoir invoquer REPL directement depuis RShell puis de revenir à RShell une fois la session REPL terminée.

La commande repl permet d’initier la session REPL et de saisir du code Python interprété directement sur la plateforme MicroPython.

L’exemple suivant montre le passage dans une session REPL et la saisie de quelques instructions Python. L’instruction os.listdir() permet de lister les fichiers présents dans la mémoire flash où il est possible d’identifier le fichier test.py récemment transféré.

images/03RI40.pngimages/03RI40.png
 

Ouverture d’une session REPL depuis RShell

 

Il est possible de quitter la session REPL et revenir à RShell en pressant la combinaison de touches [Ctrl] X (il est parfois nécessaire de répéter l’opération plusieurs fois). La combinaison de touches [Ctrl] D saisie dans une session REPL effectue une réinitialisation logicielle de la plateforme MicroPython (dite « Soft Reboot »).

La session REPL permet de charger un module Python à la volée. Cela permet de tester facilement les fonctions et les classes d’un module utilisateur en cours d’écriture. Si le module contient du code exécutable (ce qui est le cas de test.py), alors ce code sera exécuté à l’importation du module.

Pour rappel, le fichier test.py contient le code suivant :

from machine import Pin 

p = Pin(0, Pin.OUT ) # Broche attachée a la LED rouge 

p.value( 0 ) # Allumer la LED rouge / logique inversée

 Saisissez les instructions suivantes dans la session REPL :

import test 

dir() 

dir(test) 

test.p.value()

images/03RI41.pngimages/03RI41.png
 

Importer un module Python

La première instruction import test importe le module test.py et en exécute le contenu. Ce qui a pour effet d’allumer la LED rouge attachée sur la broche GPIO #0.

images/03RI42.pngimages/03RI42.png
 

Résultat de l’exécution du fichier test.py

La seconde instruction dir() retourne une liste de noms des objets/variables de portée globale disponibles pour l’interpréteur. La liste reprend le module « test » ainsi que le module « uos » importé avec la commande import os (le « u » indique qu’il s’agit d’une bibliothèque du firmware MicroPython).

La troisième instruction dir(test) affiche la liste des noms rattachés au module test (donc les déclarations contenues dans le fichier test.py). Cette fois, la liste reprend la classe Pin et la variable p attachée au GPIO #0.

La quatrième instruction test.p.value() permet d’interroger directement l’état de la broche par l’intermédiaire de la variable p du module test. La valeur 0 retournée indique que la broche est au niveau bas, signifiant que la LED rouge est allumée (logique inversée).

 La LED peut être éteinte en saisissant l’instruction suivante sur l’invite REPL.

test.p.value( 1 )

 Pressez [Ctrl] X pour quitter la session REPL et revenir à l’invite de commandes RShell.

images/03RI43.pngimages/03RI43.png
 

Retour à l’invite RShell

 Pressez [Ctrl] D pour quitter l’invite de commandes RShell et revenir au shell Linux.

5. Ampy

Ampy (Adafruit MicroPython) est un utilitaire alternatif à RShell. Ampy est produit par Adafruit Industries pour le support de ses différentes plateformes fonctionnant sous MicroPython ou CircuitPython (une version de MicroPython personnalisée par Adafruit qui intègre déjà des pilotes pour différents senseurs et composants).

Ampy est un utilitaire Python tout comme RShell mais les ressemblances s’arrêtent là. Si RShell se présente comme un couteau suisse très pratique, Ampy opte pour la philosophie du « plus simple possible ». Ainsi, Ampy propose un utilitaire en ligne de commande uniquement centré sur la manipulation de fichiers, et l’exécution de code envoyé vers la plateforme MicroPython. Au contraire de RShell, Ampy ne propose pas de prise en charge du REPL, il faudra alors revenir à un utilitaire comme picocom déjà présenté dans ce chapitre.

Cette simplicité se retrouve également dans la syntaxe de l’utilitaire, Ampy est donc un outil idéal pour les premiers pas. Il est important de noter que Ampy n’exploite que la communication série pour dialoguer avec la plateforme MicroPython.

Cette section couvre l’installation et l’utilisation de Ampy.

Ampy utilise aussi bien Python 2.7 que Python 3. Étant donné qu’un Raspberry Pi est utilisé comme hôte, Ampy peut être installé à l’aide de la commande suivante :

sudo pip install adafruit-ampy

images/03RI50.pngimages/03RI50.png
 

Installation de Ampy sur un Raspberry Pi

Ampy est un utilitaire en ligne de commande utilisant des OPTIONS pour configurer la connexion série et des COMMANDES pour manipuler le système de fichier. Le texte ci-dessous reprend une version simplifiée du texte d’aide :

ampy [OPTIONS] COMMANDES [ARGUMENTS]...  

 

Options:  

 -p PORT : Obligatoire. Nom du port série à utiliser.  

 -b BAUD : Débit de la connexion série (115200 par défaut). 

 --version : Affiche la version du programme.  

 --help : Affiche le message d’aide complet.  

 

Commandes:  

 get    Télécharge un fichier présent sur la carte.  

 ls     Liste le contenu d’un répertoire présent sur la carte.  

 mkdir  Crée un répertoire sur la carte.  

 put    Téléverse un fichier (ou un répertoire et son contenu) 

            sur la carte.  

 reset  Effectue une réinitialisation logicielle de l’interpréteur  

            MicroPython présent sur la carte.  

 rm     Enlève un fichier de la carte.  

 rmdir  Enlève un répertoire (et son contenu) de la carte.  

 run    Exécute un script sur la carte et affiche les résultats.

La syntaxe de la commande ampy et les commandes sont documentées :

Ayant déjà identifié le port série associé à la carte MicroPython (ttyUSB0, cf. Charger le firmware MicroPython dans ce chapitre), l’exemple suivant se propose d’exécuter un script sur la plateforme MicroPython branchée sur le port USB du Raspberry Pi. Le résultat de l’exécution sera récupéré par Ampy et affiché sur la console.

 Saisissez les commandes suivantes pour créer le fichier ampy-test.py dans le répertoire local du Raspberry Pi et en éditer le contenu avec Nano.

touch ampy-test.py 

nano ampy-test.py

 Puis saisissez le code Python suivant dans l’éditeur de texte Nano.

print( ’MicroPython compte par 2 jusque 20’ ) 

for compteur in range( 0, 21, 2 ): 

   print( compteur ) 

print( ’Voilà c\’est fait !’ )

images/03RI51.pngimages/03RI51.png
 

Édition du fichier ampy-test.py

 

Utiliser la combinaison [Ctrl] O pour sauver le contenu du fichier puis [Ctrl] X pour quitter l’éditeur Nano (et revenir à l’invite de commandes).

Pour finir, la commande ci-dessous exécute le contenu du fichier en mode REPL sur la plateforme MicroPython et récupère le résultat produit par l’exécution.

ampy -p /dev/ttyUSB0 run ampy-test.py

Ce qui produit le résultat suivant :

images/03RI52.pngimages/03RI52.png
 

Exécution du script ampy-test.py sur l’ESP8266

L’utilitaire Ampy permet également d’effectuer des opérations sur le système de fichiers MicroPython. Il sera ainsi possible de transférer des fichiers depuis le Raspberry Pi (ou autre ordinateur) vers la carte MicroPython (et vice-versa). En outre, Ampy permet la gestion de l’arborescence des fichiers et de répertoires sur la carte.

Avec la commande put, Ampy copiera un fichier depuis l’ordinateur vers la plateforme MicroPython. L’exemple suivant copie le fichier ampy-test.py précédemment créé dans le répertoire racine de la carte MicroPython en lui assignant un nouveau nom.

ampy -p /dev/ttyUSB0 put ampy-test.py ampytest.py

 

Le transfert de fichier avec la commande put écrase toujours le contenu du fichier sur la carte MicroPython.

 

Le tiret - a été enlevé du nom du fichier durant de la copie. Si le système de fichier supporte très bien le tiret dans un nom de fichier, l’importation de bibliothèque Python ne tolère pas ce caractère dans un nom de bibliothèque !

Il est possible, de lister les fichiers présents dans le répertoire racine (ou sous-répertoire) de la carte en utilisant la commande ls.

ampy -p /dev/ttyUSB0 ls

Ce qui affiche la liste des fichiers dans laquelle il est possible d’identifier ampytest.py mais également le fichier d’exemple créé durant la présentation de RShell.

images/03RI53.pngimages/03RI53.png
 

Résultat de la commande ampy « ls »

L’utilitaire picocom, permet de démarrer une session REPL sur la carte MicroPython et d’exécuter le contenu du script ampytest.py directement depuis la carte MicroPython.

sudo picocom /dev/ttyUSB0 -b115200

Une fois le terminal démarré et l’invite de commandes MicroPython visible >>>, la saisie de l’instruction import ampytest chargera le fichier ampytest.py pour en exécuter le contenu.

 

Il est parfois nécessaire de presser la touche retour clavier [Enter] pour obtenir l’invite de commandes REPL >>>.

images/03RI54.pngimages/03RI54.png
 

Exécution du contenu de ampytest.py depuis l’invite REPL

 

Presser la combinaison de touches [Ctrl] A et [Ctrl] X pour quitter picocom et revenir à l’invite de commandes du système d’exploitation.

Ampy permet de récupérer des fichiers présents sur la carte MicroPython en utilisant la commande get.

Lorsque le nom de fichier local est précisé en deuxième paramètre, la commande get fait une copie du fichier de la carte MicroPython vers le système de fichier local. Si aucun nom de fichier n’est précisé pour la copie locale (deuxième paramètre omis), alors Ampy affiche le contenu du fichier vers le périphérique de sortie (donc le terminal). Cette particularité permet de consulter rapidement le contenu d’un fichier présent sur la carte MicroPython.

L’exemple suivant affiche le contenu du fichier boot.py présent sur la carte, puis en fait une copie vers le système de fichier local du Raspberry Pi (en précisant le nom que doit avoir le fichier copié). Pour finir, la commande ls b*.py permet de vérifier la présence de la copie locale avant d’utiliser cat pour en afficher le contenu.

ampy -p /dev/ttyUSB0 get boot.py 

ampy -p /dev/ttyUSB0 get boot.py boot.py 

ls b*.py 

cat boot.py

images/03RI55.pngimages/03RI55.png
 

Utilisation de la commande ampy « get »

Pour terminer cette présentation de Ampy, l’exemple suivant utilise la commande rm (de l’anglais remove) pour enlever le fichier ampytest.py présent sur la carte MicroPython. La commande ampy ls permet de vérifier l’effacement du fichier sur la carte.

ampy -p /dev/ttyUSB0 rm ampytest.py 

ampy -p /dev/ttyUSB0 ls

images/03RI56.pngimages/03RI56.png
 

Utilisation de la commande ampy « rm »

Voilà qui termine l’introduction des outils permettant de dialoguer avec les plateformes ESP8266 sous MicroPython.

WebREPL

WebREPL est une fonctionnalité avancée de MicroPython sur ESP8266 qui permet d’initier une session REPL par l’intermédiaire d’une interface web exécutée au sein d’un navigateur internet. WebREPL s’appuie sur le service Python WebREPL qu’il faut activer sur l’ESP8266 (sécurité oblige !). Ce service expose un WebSocket permettant à la partie cliente de WebREPL d’interagir avec la plateforme MicroPython.

L’activation du service WebREPL s’effectue via une session REPL, donc via la liaison série. L’activation du service permet de définir le mot de passe qui protégera ensuite les sessions WebREPL accessibles via l’interface Wi-Fi.

Le client WebREPL se présente comme suit :

images/03RI60.pngimages/03RI60.png
 

Interface WebREPL

L’interface WebREPL propose les options suivantes :

1.

Zone de saisie de l’adresse de la carte MicroPython. Cette adresse est préfixée par « ws:// », car il s’agit d’une connexion Web Socket. Le suffixe «:8266 » indique le numéro de port à utiliser (8266 pour rappeler la plateforme ESP8266). L’adresse IP 192.168.4.1 est l’adresse par défaut des ESP8266 démarrant en mode point d’accès. Si l’ESP8266 est connecté sur le réseau Wi-Fi domestique, alors l’adresse IP de l’ESP8266 sur ce réseau domestique peut être utilisée. Celle-ci se présente souvent sous la forme 192.168.1.x. Pour finir, lorsque la résolution DNS le permet, il est également possible d’employer le nom d’hôte de la plateforme MicroPython. Celle-ci se présente sous la forme ESP_xxxxxx où xxxxxx sont les 3 derniers octets de l’adresse MAC de l’interface Wi-Fi de l’ESP8266. Ex. : ws://ESP_88BB0E:8266.

2.

Bouton Connect/Disconnect : permet d’établir (ou de terminer) une connexion avec le Web Socket de l’ESP8266. Le mot de passe WebREPL est requis dès la connexion établie. Les autres parties de l’interface WebREPL peuvent ensuite être utilisées.

3.

Envoi d’un fichier vers MicroPython : permet d’envoyer un fichier (un script Python) dans le répertoire racine de l’ESP8266. Si le fichier existe déjà sur la carte, alors son contenu est écrasé.

4.

Télécharger un fichier depuis MicroPython : permet de récupérer un fichier stocké dans le répertoire racine de l’ESP8266 dès lors que son nom est connu. Le fichier téléchargé dans le navigateur est disponible dans la zone des téléchargements du navigateur.

5.

Zone de saisie REPL. Une fois le client WebREPL connecté sur l’ESP8266, cette zone texte permet de saisir des instructions et d’afficher des résultats. L’invite REPL et les comportements restent identiques à ceux d’une simple session REPL.

1. Le démon WebREPL

Pour utiliser WebREPL, il est nécessaire d’activer et de de configurer le démon WebREPL sur la carte ESP8266. Ce démon est un service qui fonctionne en arrière-plan.

La configuration se limite à un mot de passe.

a. Activer WebREPL sur l’ESP8266

 Après avoir établi une session REPL série, la configuration est réalisée à l’aide de la commande suivante :

import webrepl_setup

 Cela importe et exécute le module de configuration. La première question demande s’il faut activer le démon WebREPL sur l’ESP8266. Répondez « E » (de l’anglais Enable) pour activer le démon.

 Un mot de passe doit ensuite être saisi (et confirmé). Ce dernier servira à protéger la connexion WebREPL.

 Pour finir, l’outil de configuration demande s’il faut redémarrer la plateforme ESP8266 (afin d’activer le démon WebREPL). Répondez « Y » pour redémarrer la plateforme, ce qui aura pour effet de réinitialiser la connexion série.

images/03RI61.pngimages/03RI61.png
 

Activation du démon WebREPL sur la plateforme ESP8266

Voilà, le WebSocket WebREPL est maintenant actif sur l’ESP8266.

Bien que le script de configuration propose de procéder à une réinitialisation logicielle de la carte (Soft Reboot), cette opération n’est pas suffisante pour démarrer correctement le démon WebREPL. Le redémarrage logiciel, qu’il est aussi possible d’initier avec [Ctrl] D, affiche systématiquement le message « WebREPL is not configured, run ’import webrepl_setup’ ».

Une réinitialisation matérielle est nécessaire pour démarrer le démon WebREPL. Il faut presser le bouton Reset de la carte.

images/03RI16.pngimages/03RI16.png
 

Réinitialisation matérielle de la carte avec le bouton Reset (Rst)

b. Le mot de passe WebREPL

Une fois WebREPL activé, l’ESP8266 contient un nouveau fichier nommé webrepl_cfg.py contenant la constante PASS définissant le mot de passe WebREPL.

Les instructions Python suivantes saisies en REPL listent les fichiers présents sur la carte puis affichent le contenu du fichier webrepl_cfg.py.

images/03RI62.pngimages/03RI62.png
 

Afficher le contenu du fichier de configuration webrepl_cfg.py

 

Le contenu du fichier est retourné sous la forme d’une chaîne de caractères. Le \n indique un retour à la ligne. Le fichier webrepl_cfg.py ne contient donc qu’une seule instruction Python qui est PASS = ’8266-100’.

Il est donc possible de consulter ou modifier le mot de passe à volonté en passant par la liaison série. Les utilitaires RShell et Ampy proposent des solutions de manipulation de fichier permettant de copier le fichier webrepl_cfg.py sur le Raspberry Pi/ordinateur pour en modifier le contenu avant de renvoyer le fichier sur l’ESP8266. Il sera nécessaire de redémarrer le démon WebREPL en réinitialisant l’ESP8266.

2. Client WebREPL

Les ressources de la plateforme ESP8266 étant limitées, la plateforme MicroPython ne stocke pas le client WebREPL dans sa mémoire Flash. Le client WebREPL ne peut pas être téléchargé depuis un serveur HTTP activé sur l’ESP8266 ! Le seul élément que la plateforme ESP8266 embarque est le WebSocket WebREPL.

Il est nécessaire de charger le client WebREPL dans le navigateur web depuis l’une des sources suivantes :

 Chargez le client directement depuis http://micropython.org/webrepl/.

 Obtenez une copie de WebREPL depuis le dépôt GitHub (https://github.com/micropython/webrepl) puis chargez-la dans le navigateur web depuis le système de fichier de votre ordinateur.

Nom d’hôte et adresse MAC

La présence de plusieurs cartes ESP8266 connectées sur le réseau Wi-Fi domestique peut rendre pénible l’identification d’un objet sur la base d’une adresse IP dynamique.

Connaître le nom d’hôte (nom sur le réseau) de la carte ESP8266 est commode pour contacter la carte en utilisant ce nom en lieu et place de l’adresse IP. Par ailleurs, connaître l’adresse MAC des différents ESP8266 permet d’identifier chaque carte de manière univoque.

Les lignes suivantes, saisies sur une invite REPL, permettent d’extraire une partie de l’adresse MAC et de reconstituer le nom d’hôte de la carte ESP8266.

from network import WLAN  

wlan=WLAN()  

wlan.config(’mac’)

Ce qui produit le résultat suivant :

images/03RI63.pngimages/03RI63.png
 

Obtention de l’adresse MAC de la carte ESP8266

Le tableau d’octets b’\\\xcf\x7f\xef\xb1\xd3’ renvoyé pour la configuration de l’adresse MAC (adresse physique de la carte sur le réseau) indique que les 5 derniers octets sont CF:7F:EF:B1:D3.

La carte ESP8266 utilise un nom d’hôte composé de « ESP_ » suivi des 3 derniers octets de l’adresse MAC. Dans le cas présent, le nom d’hôte sera ESP_EFB1D3.

Le mode point d’accès (AP)

Par défaut, l’interface Wi-Fi de l’ESP8266 démarre en mode point d’accès. Cela signifie que la carte crée son propre réseau Wi-Fi sur lequel un ordinateur, un smartphone, etc. peut se connecter en tant que station.

Le nom du réseau Wi-Fi créé par la carte ESP8266 est « MicroPython-xxxxxx » où les xxxx sont une partie de l’adresse MAC de l’ESP8266.

images/03RI68.pngimages/03RI68.png
 

ESP8266 en mode point d’accès

 

L’interface Wi-Fi de l’ESP8266 peut aussi être configurée en mode station (STA) afin de connecter la carte sur un réseau Wi-Fi existant. Ce point spécifique est abordé plus loin dans le chapitre.

En vue d’établir une connexion WebREPL, il est recommandé d’utiliser un ordinateur disposant d’un navigateur Firefox ou Chrome.

La première étape consiste à se connecter sur le point d’accès MicroPython en utilisant le gestionnaire réseau du système d’exploitation (Linux Mint dans la capture ci-dessous).

images/03RI64.pngimages/03RI64.png
 

PC se connectant sur le point d’accès MicroPython créé par l’ESP8266

Le mot de passe par défaut de la connexion Wi-Fi est micropythoN (avec le N en majuscule).

images/03RI65.pngimages/03RI65.png
 

Saisie du mot de passe Wi-Fi du réseau point d’accès de ESP8266

Session WebREPL en mode point d’accès

Le client WebREPL est disponible sur http://micropython.org/webrepl/. Il peut également être téléchargé sous forme d’archive depuis le dépôt GitHub (https://github.com/micropython/webrepl) dont il faut extraire le contenu avant de charger le fichier webrepl.html avec votre navigateur.

Fonctionnant en mode point d’accès, l’adresse IP du module ESP8266 est 192.168.4.1, de sorte qu’il n’est pas nécessaire de modifier l’adresse dans le client avant de presser sur le bouton Connect.

images/03RI66.pngimages/03RI66.png
 

Connexion sur ESP8266 en mode point d’accès

Une fois le bouton Connect pressé, le module ESP8266 invite le client à saisir le mot de passe WebREPL (celui configuré durant l’activation du démon WebREPL sur la carte).

L’invite REPL (>>>) est disponible après la saisie du mot de passe.

images/03RI67.pngimages/03RI67.png
 

Invite REPL sur le client WebREPL

Le mode station (STA)

La section précédente a couvert le fonctionnement du module ESP8266 en mode point d’accès. Dans ce mode, l’ESP8266 crée son propre réseau Wi-Fi sur lequel viennent se connecter ordinateurs, smartphones, tablettes, etc.

Le mode point d’accès (AP) est le mode de fonctionnement par défaut du module ESP8266. Ce n’est cependant pas le mode de fonctionnement le plus intéressant pour exploiter un réseau de capteurs à base d’ESP8266.

Cette section aborde la découverte et l’utilisation du mode station (STA).

images/03RI69.pngimages/03RI69.png
 

ESP8266 en mode STAtion

Le mode station (STA) permet au module ESP8266 de se connecter sur un réseau Wi-Fi existant. Il est ainsi possible d’avoir plusieurs ESP8266 cohabitant sur le réseau local et tous accessibles depuis n’importe quel ordinateur présent sur le même réseau.

L’utilisation du réseau local s’avère également plus appropriée dans le cadre de ce projet étant donné que les différents ESP8266 publieront des données vers un serveur MQTT (un Raspberry Pi également enregistré sur le réseau local).

 

Il est tentant de réutiliser la connexion WebREPL en mode point d’accès (PA) pour tester le code ci-dessous. Il est néanmoins recommandé d’utiliser une connexion REPL standard (via connexion série) pour effectuer les opérations de connexion au réseau.

1. Mode STA et scan réseau

Les commandes suivantes, saisies sur une invite REPL, permettent de détecter les réseaux Wi-Fi à portée de l’ESP8266.

01: from network import *  

02: wlan = WLAN( STA_IF )  

03: wlan.active( True ) 

03: for wifi in wlan.scan():  

04:    print( wifi )

Ligne 1 : permet d’importer les classes et constantes nécessaires.

Ligne 2 : crée un objet WLAN permettant d’interagir avec l’interface Wi-Fi du module ESP8266. Le paramètre STA_IF indique que l’interface doit être configurée en mode STATION (client réseau). 

Ligne 3 : activation de l’interface STA.

Ligne 4 : détection des réseaux à proximité. La méthode scan() retourne une liste de tuples. Ces tuples sont ensuite affichés par la commande print(). Les réseaux Wi-Fi annoncent régulièrement leur identifiant SSID, de fait le contenu de la méthode scan() peut évoluer d’un appel à l’autre.

images/03RI70.pngimages/03RI70.png
 

Détection des réseaux à proximité de l’ESP8266

Chaque tuple reprend les informations suivantes :

2. Réseau Wi-Fi visible ou masqué

La plupart des modems-routeurs récents sont capables de fonctionner en mode masqué. Cela signifie qu’ils n’annoncent pas publiquement le réseau Wi-Fi (leur SSID). A priori, ces modems-routeurs ne seront pas identifiés lors de la détection des réseaux Wi-Fi à l’aide de la fonction WLAN.scan(). Cela n’empêchera pas de s’y connecter à condition de connaître le BSSID (adresse MAC) du réseau en question. Ce BSSID sera alors utilisé en lieu et place du SSID lors de l’établissement de la connexion Wi-Fi depuis MicroPython (utilisation d’un paramètre BSSID et non du paramètre SSID).

Lors des premières expérimentations, il est recommandé d’utiliser un réseau non masqué afin de ne pas multiplier les obstacles et difficultés.

3. Connexion en mode STA

Ayant identifié le réseau à utiliser parmi les réseaux Wi-Fi disponibles (ex. : ATCG103) et connaissant le mot de passe associé, les instructions suivantes permettent de connecter le module ESP8266 au réseau Wi-Fi disposant d’un service DHCP (allocation dynamique d’adresse IP).

La section ci-dessous reprend une transcription de la session REPL.

01 >>> import network 

02 >>> wlan = network.WLAN( network.STA_IF ) 

03 >>> wlan.active( True ) 

04 >>> wlan.connect( "ATCG103", "motdepasse" ) 

05 >>> while not wlan.isconnected(): 

06 ...     pass 

07 ...      

08 ...      

09 ...  

10 >>> print( wlan.isconnected() ) 

11 True  

12 >>> print( wlan.ifconfig() ) 

13 (’192.168.1.9’, ’255.255.255.0’, ’192.168.1.1’, ’192.168.1.1’)

Ligne 1 : importer le module network pour avoir accès aux classes et constantes nécessaires.

Ligne 2 : créer un objet de type WLAN en activant l’interface en mode STATION (STA_IF : STAtion InterFace) afin de se connecter sur un réseau Wi-Fi.

Ligne 3 : activer l’interface Wi-Fi.

Ligne 4 : connexion sur le réseau Wi-Fi ayant le SSID « ATCG103 » avec le mot de passe mentionné.

 

La syntaxe d’une connexion avec BSSID serait :

wlan.connect( bssid=b’valeur_du_bssid’, password="motdepasse" )

Lignes 5 et suivante : boucle d’attente jusqu’à ce que la connexion soit établie.

Ligne 12 : affichage de la configuration réseau où l’on retrouve :

 

Le module Wi-Fi de l’ESP8266 restaure automatiquement la configuration Wi-Fi après chaque démarrage (cycle d’alimentation). Cela signifie que le module rétablit la connexion Wi-Fi à partir des dernières informations d’authentification connues.

Une fois la connexion établie avec le réseau Wi-Fi, il est nécessaire de réinitialiser matériellement la plateforme MicroPython pour bénéficier du service WebREPL sur le réseau local (si, bien entendu, le service WebREPL est actif). Conformément à la remarque ci-dessus, la connexion Wi-Fi sera automatiquement ré-établie avec le réseau Wi-Fi.

4. WebREPL en mode STA

Une fois l’ESP8266 connecté sur le réseau et promptement réinitialisé, il est possible d’établir une connexion WebREPL au travers du réseau local.

Il suffit alors de remplacer le 192.168.4.1 par l’adresse IP sur le réseau (soit 192.168.1.9) avant de presser le bouton Connect. Dans le cas présent, l’adresse sera :

ws://192.168.1.9:8266

Comme vous pouvez le constater sur la capture suivante, il est possible de réinterroger l’interface réseau pour en extraire la configuration actuelle.

images/03RI71.pngimages/03RI71.png
 

Session WebREPL sur le réseau local

Le service DHCP assigne automatiquement une adresse IP et obtenir une session sur la base d’une adresse IP variable n’est pas forcément confortable.

Sur un réseau local, il est également possible d’établir la connexion WebREPL en utilisant le nom d’hôte de l’ESP8266.

Dans l’exemple ci-dessous, le nom d’hôte ESP_EFB1D3 est utilisé pour établir la session WebREPL. 

images/03RI72.pngimages/03RI72.png
 

Utilisation du nom d’hôte pour contacter l’ESP8266

 

La résolution du nom d’hôte des ESP8266 sur le réseau local n’est néanmoins pas toujours possible. Il est possible d’identifier l’adresse IP dynamique à partir de l’adresse MAC connue de l’ESP8266 en utilisant l’utilitaire nmap (voir plus loin) disponible sur le Raspberry Pi.

 

Le navigateur Chrome utilise exclusivement les serveurs DNS de Google pour la résolution des noms. Sous Chrome/Chromium, il est donc impossible d’atteindre un ESP8266 en utilisant son nom d’hôte.

5. Désactivation du point d’accès

Maintenant que l’ESP8266 est connecté en mode station sur un réseau Wi-Fi, il n’est plus utile de préserver le point d’accès Wi-Fi (AP) sur le module ESP8266.

Les deux interfaces STA et AP peuvent coexister en même temps sur le module. Une fois connecté en mode STA, il n’est pas utile d’annoncer la présence du module ESP8266 dans le voisinage en maintenant active l’interface AP (Point d’Accès).

Le code suivant vérifie l’état de la connexion en mode station, puis désactive l’interface point d’accès (AP) sur le module ESP8266.

01: Import network 

02: sta = network.WLAN( network.STA_IF ) 

03: if sta.active() and (sta.status() == network.STAT_GOT_IP ): 

04:     ap = network.WLAN( network.AP_IF )  

05:     if ap.active():  

06:         ap.active(False)

Ligne 1 : importer le module network pour avoir accès aux classes et constantes nécessaires.

Ligne 2 : obtenir une référence sur l’interface station.

Ligne 3 : vérifier que l’interface station est active et a reçu une adresse IP.

Ligne 4 : obtenir une référence sur l’interface Point d’accès.

Lignes 5 - 6 : si le point d’accès est actif, alors le désactiver.

À la suite de ces instructions, le point d’accès Wi-Fi « MicroPython-xxx » sera désactivé et disparaîtra de la liste des réseaux Wi-Fi disponibles.

6. Rechercher l’adresse IP d’un ESP8266

En installant l’utilitaire nmap sur le Raspberry Pi, il est possible de scanner le réseau à la recherche des interfaces qui y sont actives. Nmap permet d’identifier les adresses IP correspondantes aux adresses MAC relevées sur les différents modules ESP8266.

La commande suivante installe l’utilitaire nmap :

sudo apt-get install nmap

Une fois nmap installé, la commande suivante lance une recherche d’adresses IP (par ping) sur un réseau 192.168.1.x. Cette opération affiche les adresses IP, les adresses MAC correspondantes et les hostname si disponibles.

sudo nmap -sn 192.168.1.0/24

Les ESP8266 ont une adresse MAC commençant par « 5C:CF ».

Dans l’extrait du résultat Nmap ci-dessous, il est possible d’identifier l’adresse IP 192.168.1.9 correspondant à notre ESP_EDB1D3 (adresse MAC terminant par ED:B1:D3).

pi@pythonic:~ $ sudo nmap -sn 192.168.1.0/24  

 

Starting Nmap 6.47 ( http://nmap.org ) at 2017-09-18 21:16 UTC  

... 

Nmap scan report for 192.168.1.9  

Host is up (0.062s latency).  

MAC Address: 5C:CF:7F:EF:B1:D3 (Unknown) 

... 

Nmap done: 256 IP addresses (18 hosts up) scanned in 4.87 seconds

Séquence de démarrage MicroPython

La séquence de démarrage MicroPython est initiée à chaque mise sous tension, chaque réinitialisation matérielle et chaque réinitialisation logicielle ([Ctrl] D) de la plateforme.

La séquence de démarrage met en œuvre deux fichiers importants qui doivent se trouver dans la racine du système de fichiers MicroPython. Il s’agit des fichiers boot.py et main.py.

1. Fichier boot.py

La plateforme MicroPython doit contenir un fichier boot.py présent dans la racine du système de fichier.

Le fichier boot.py est le premier fichier chargé par MicroPython. Ce dernier doit contenir les instructions destinées à la configuration de bas niveau de la plateforme. Le contenu de boot.py doit être concis, son contenu est destiné à finaliser le démarrage de la carte MicroPython.

Dans le cadre d’un module ESP8266, boot.py contiendra le code de configuration de l’interface Wi-Fi.

Une fois le contenu du fichier boot.py traité par MicroPython, la plateforme chargera et exécutera le contenu du fichier main.py.

MicroPython poursuit la séquence de démarrage même si le fichier boot.py provoque une erreur.

 

Attention : la séquence de démarrage est suspendue aussi longtemps que le contenu du fichier boot.py est exécuté ! Une boucle infinie dans boot.py bloquera la séquence de démarrage de la plateforme. Cela peut avoir une incidence sur des outils comme RShell étant donné que ceux-ci exécutent une réinitialisation logicielle en début de session. En cas de blocage dans boot.py, reflasher la plateforme sera la solution la plus efficace pour retrouver un système opérationnel.

2. Fichier main.py

Si ce fichier existe à la racine du système de fichiers, ce dernier sera automatiquement chargé et exécuté à la fin de la séquence de démarrage.

Le fichier main.py est destiné à recevoir le programme utilisateur (script Python) qui est exécuté à chaque démarrage de la plateforme MicroPython.

3. Un fichier boot.py pour ESP8266

Voici une suggestion de fichier boot.py pour un module ESP8266 connecté en mode station sur le réseau Wi-Fi. Le fichier pourra être téléversé sur la plateforme à l’aide d’un outil tel que RShell ou Ampy.

Une copie de cet exemple est disponible dans le fichier boot_simple.py présent dans le répertoire esp8266/boot/ du dépôt GitHub de l’ouvrage.

01: WIFI_SSID = "MY_WIFI_SSID"  

02: WIFI_PASSWORD = "MY_PASSWORD" 

03: 

04: def sta_connect():  

05:     import network 

06:     wlan = network.WLAN(network.STA_IF)  

07:     wlan.active(True)  

08:     if not wlan.isconnected():  

09:         # connecting to network...  

10:         wlan.connect( WIFI_SSID, WIFI_PASSWORD )  

11: 

12:         while not wlan.isconnected():  

13:             pass 

14: 

15: sta_connect()  

16: 

17: import gc  

18: #import webrepl  

19: #webrepl.start()  

20: gc.collect()

Lignes 1-2 : permettent de définir l’identifiant du réseau Wi-Fi et le mot de passe de ce dernier.

Ligne 4 : définit la fonction sta_connect() configurant l’interface Wi-Fi en mode station.

Ligne 15 : appel/exécution de la fonction de connexion.

Ligne 17 : import du module « gb » (Garbage Collector = ramasse-miettes).

Lignes 18-19 : module et démon WebREPL (pas activé étant donné que les lignes sont en commentaires). 

Ligne 20 : nettoyage du ramasse-miettes (collecte des références non utilisées et libération de la mémoire disponible).

La fonction sta_connect() permet de connecter le module ESP8266 sur le réseau Wi-Fi.

Lignes 5-7 : importation du module réseau, création d’un objet WLAN en mode station. Activation de l’interface Wi-Fi en mode station (si cela est nécessaire).

Ligne 8 : pour rappel, l’ESP8266 se reconnecte automatiquement sur le dernier réseau Wi-Fi connu par le module. Cette ligne vérifie que le module ESP8266 n’est pas reconnecté sur un réseau Wi-Fi avant de lancer la procédure de connexion.

Ligne 10 : connexion sur le réseau Wi-Fi en utilisant les paramètres SSID (identification du réseau Wi-Fi) et mot de passe Wi-Fi déclarés en début de module.

Lignes 12-13 : boucle d’attente de la connexion.

a. Script trop optimiste et conséquences

Bien que ce script soit l’exemple le plus souvent mentionné, ce dernier reste trop optimiste. Il part du principe que les informations de connexions (SSID et mot de passe) sont correctes et que le réseau Wi-Fi est disponible.

Si l’une de ces informations est incorrecte ou si le réseau n’est pas/plus disponible, le script boot.py restera bloqué dans la boucle infinie des lignes 12 à 13.

while not wlan.isconnected():  

   pass

En conséquence, les outils avancés tels que RShell (et probablement Ampy) ne fonctionneront plus comme attendu. Lors de l’envoi de commandes via le port série, RShell effectue une réinitialisation logicielle (équivalent du [Ctrl] D) avant de débuter une session REPL. Étant donné que le fichier boot.py entrera dans une boucle infinie, cela bloquera le fonctionnement de RShell (RShell retournera un message d’erreur après un timeout d’une dizaine de secondes).

 

La seule option permettant de rétablir le fonctionnement de l’ESP8266 est la réinitialisation du module en reflashant le firmware MicroPython.

En solution intermédiaire, il est possible de remplacer la boucle d’attente infinie des lignes 12-13 par une boucle d’attente avec timeout de 40 secondes comme celle-ci dessous.

import time 

ctime = time.time() 

while not wlan.isconnected(): 

   if (time.time()-ctime > 40 ) : 

       print( ’timeout reached !’) 

       break; 

   time.sleep(0.5)

Il sera alors possible d’utiliser picocom (déjà présenté dans ce chapitre) pour établir une session REPL via la connexion série. Chaque réinitialisation ([Ctrl] D) exécutera le fichier boot.py qui rendra la main au système au bout de 40 secondes en cas de problème. Si ce délai reste trop important pour RShell, cela permet au moins d’utiliser picocom pour désactiver le fichier boot.py en le renommant dead.py à l’aide des commandes suivantes saisies dans la session REPL obtenue au terme des 40 secondes de timeout.

import os  

os.rename( ’boot.py’, ’dead.py’ )

b. RunApp - Activation de l’application

En cas de perte de contrôle, il est parfois utile d’abréger/désactiver le fonctionnement de boot.py et de main.py à partir d’une configuration matérielle.

En plaçant un petit interrupteur (ou cavalier) sur la broche 12 configurée en entrée, il devient possible de désactiver ou altérer le fonctionnement de boot.py (ainsi que de main.py). Cette approche est très pratique pour reprendre la main sur une application en perte de contrôle. En plaçant l’interrupteur sur RunApp = 0 et en redémarrant l’ESP8266, le module redémarre sans bloquer la connexion Wi-Fi et sans démarrer le script principal main.py.

Dans le montage ci-dessous, la broche 12 du Feather ESP8266 est branchée à la masse (GND) par l’intermédiaire d’un interrupteur.

images/03RI75.pngimages/03RI75.png
 

Utilisation d’un interrupteur/cavalier RunApp

La lecture de l’état de l’entrée se fait comme suit :

01: from machine import Pin 

02: runapp = Pin( 12,  Pin.IN, Pin.PULL_UP ) 

03: if runapp.value() == 0: 

04:     print( ’Arret application. RunApp=0’ ) 

05: else: 

06:     print( ’Executer application. RunApp=1’ )

Ligne 1 : charge la classe Pin depuis le module machine. La classe Pin permet d’interagir avec les broches de l’ESP8266.

Ligne 2 : création de runapp, un objet de type Pin, permettant de lire la valeur de la broche n° 12. Le paramètre Pin.IN indique que la broche doit être initialisée en entrée et le paramètre optionnel Pin.PULL_UP active la résistance pull interne sur la broche n° 12).

Ligne 3 : l’instruction runapp.value() permet de lire la valeur de l’entrée numérique. La valeur retournée sera égale à 1 (équivalent de True) si l’entrée est au niveau haut (3,3 V). La valeur 0 (équivalent de False) sera retournée en cas de niveau logique bas (0 V ou la masse).

 

Une résistance pull-up est utilisée pour ramener le potentiel d’une broche au niveau logique haut (3,3 V) à moins qu’une action extérieure ne force le potentiel à un autre niveau logique. Par exemple, ramener le potentiel de la broche au niveau bas par l’intermédiaire d’un bouton poussoir.

images/03RI76.pngimages/03RI76.png
 

Représentation de la résistance pull-up interne

c. Un script de boot avancé

Compte tenu des points abordés ci-dessus, cette section propose un fichier boot.py offrant les fonctionnalités suivantes :

Une copie de cet exemple est disponible dans le fichier boot.py présent dans le répertoire esp8266/boot/ du dépôt GitHub de l’ouvrage.

01: WIFI_SSID = "MY_WIFI_SSID"  

02: WIFI_PASSWORD = "MY_PASSWORD"  

03:  

04: def sta_connect( timeout = None, disable_ap = False ):  

05:     import network , time 

06:     from machine import Pin 

07:     wlan = network.WLAN(network.STA_IF)  

08:     wlan.active(True)  

09:     if not wlan.isconnected():  

10:         wlan.connect( WIFI_SSID, WIFI_PASSWORD )  

11:          

12:         runapp = Pin( 12,  Pin.IN, Pin.PULL_UP )  

13:         if runapp.value() == 0:  

14:             print( "WLAN: no wait!")  

15:         else:  

16:             ctime = time.time()  

17:             while not wlan.isconnected():  

18:                 if timeout and ((time.time()-ctime)>timeout):  

19:                     print( "WLAN: timeout!" )  

20:                     break  

21:                 else:  

22:                     time.sleep( 0.500 )  

23:  

24:     if wlan.isconnected() and disable_ap:  

25:         ap = network.WLAN(network.AP_IF)  

26:         if ap.active():  

27:             ap.active(False)  

28:             print( "AP disabled!" )  

29:  

30:     return wlan.isconnected()  

31:  

32: if sta_connect( disable_ap=True, timeout=40 ):  

33:     print( "WLAN: connected!" )  

34:  

35: import gc  

36: #import webrepl  

37: #webrepl.start()  

38: gc.collect()

Lignes 1, 2 et 35 à 38 : reprennent des éléments déjà connus.

Ligne 32 : la fonction de connexion sta_connect() connecte l’ESP8266 en mode station sur le réseau Wi-Fi. disable_ap=True demande l’arrêt du point d’accès après la connexion sur le réseau Wi-Fi. timeout=40 limite le temps d’attente de connexion sur le réseau Wi-Fi à 40 secondes. Une valeur timeout=None attendra indéfiniment.

La fonction sta_connect() prend en charge la logique de connexion sur le réseau Wi-Fi :

Lignes 5-8 : importation des modules nécessaires, création de l’objet wlan en mode station et activation de l’interface WLAN (si elle n’est pas encore active).

Ligne 9 : si le module ESP8266 ne s’est pas automatiquement reconnecté sur le dernier réseau Wi-Fi connu, alors le script procède à l’établissement de la connexion.

Ligne 10 : connexion sur le réseau Wi-Fi identifié par le SSID et mot de passe mentionnés en lignes 01 et 02.

Lignes 12-14 : configuration de la broche 12 en entrée et lecture de l’état de la broche.

Ligne 16 : capture l’heure de début, ce qui permet de suivre l’écoulement du temps durant la boucle d’attente de connexion sur le réseau Wi-Fi.

Ligne 17 : boucle d’attente de connexion Wi-Fi

Ligne 18 : le test if timeout and ... permet de tester si timeout dispose d’une valeur. Si None est assigné à timeout, alors le résultat du test if timeout est faux ! Auquel cas un temps d’attente sleep(0.500) est systématiquement exécuté. Dans le cas du timeout à None les lignes 17 à 22 se comportent comme une boucle d’attente infinie. Si timeout dispose d’une valeur numérique, alors le if timeout est vrai et le restant du test (time.time()-ctime)>timeout vérifie que le temps écoulé depuis ctime n’est pas supérieur au timeout défini (40 secondes). Si le temps écoulé dépasse le timeout, l’instruction break de la ligne 20 interrompt la boucle while et poursuit l’exécution à la ligne 24.

Lignes 24-28 : obtention d’une référence vers l’interface point d’accès (AP_IF) et désactivation du point d’accès si l’interface en mode station est connectée.

Ligne 30 : retourne l’état de la connexion de l’interface en mode station.

Programmer

Cette section du chapitre va passer en revue les éléments pratiques facilitant la prise en charge et le développement de projets MicroPython sur ESP8266.

Les points suivants seront abordés :

1. Création d’une bibliothèque

Une bibliothèque regroupe un ensemble de fonctions, de définitions ou de classes dans un fichier séparé. Tous ces éléments sont rassemblés en suivant une logique dictée soit par un support matériel (cartes ADS11x5), soit par une fonctionnalité commune (MQTT), soit par des services communs (os : interaction avec le système d’exploitation).

La création d’une bibliothèque permet d’isoler des éléments réutilisables pouvant également être exploités dans d’autres projets. L’utilisation de bibliothèques permet aussi d’alléger le contenu des différents scripts Python, ce qui améliore la lecture et la maintenance du code.

Enfin, une bibliothèque peut être vue comme une boîte noire que l’on sait utiliser pour obtenir tel ou tel résultat sans pour autant en connaître les rouages internes.

 

Attention à ne pas confondre bibliothèque Python et module Python. Une bibliothèque est un fichier Python contenant du code. Un module est un sous-répertoire contenant généralement plusieurs fichiers Python contenant eux-mêmes des classes, des fonctions et des déclarations diverses.

La suite de cette section propose de créer une bibliothèque nommée « outil » qui sera ensuite utilisée pour démontrer l’usage de l’instruction Python import.

Créer un fichier outil.py contenant le code ci-dessous. Le fichier peut être créé depuis RShell en utilisant les commandes suivantes :

!touch outil.py 

!nano outil.py

Contenu du fichier outil.py :

def create_list( count, init_value ):  

   l = []  

   for i in range( count ):  

       l.append( init_value )  

   return l  

 

def add( v1, v2 ):  

   return v1 + v2  

 

COULEUR_ROSE = (255, 153, 204)

Le fichier outil.py peut être envoyé sur l’ESP8266 à l’aide de RShell avec la commande cp. Par la suite, la commande ls permet de vérifier que le fichier est bien présent dans le répertoire racine de la carte ESP8266.

images/03RI77.pngimages/03RI77.png
 

Copie de la bibliothèque outil

Une session REPL permet de tester la bibliothèque sur la plateforme MicroPython à l’aide de quelques instructions :

01: >>> from outil import *  

02: >>> print( COULEUR_ROSE )  

03: (255, 153, 204)  

04: >>> print( add(125,15) )  

05: 140  

06: >>> lst = create_list( 4, 0 )  

07: >>> print( lst )  

08: [0, 0, 0, 0]  

09: >>> lst2 = create_list( 3, COULEUR_ROSE )  

10: >>> print( lst2 )  

11: [(255, 153, 204), (255, 153, 204), (255, 153, 204)]

Ligne 1 : importation de tous les éléments de la bibliothèque outil dans l’espace de noms courant. Le fichier outil.py est ouvert et chargé par l’interpréteur MicroPython.

Ligne 2 : affichage de la valeur de la constante COULEUR_ROSE définie dans la bibliothèque. Les constantes sont définies en majuscule.

Ligne 4 : appel de la fonction add() définie dans la bibliothèque outil.

Lignes 6-10 : appel de la fonction create_list() définie dans la bibliothèque.

À noter qu’il existe différentes syntaxes utilisables pour importer une bibliothèque. Celles-ci sont passées en revue ci-dessous.

La bibliothèque peut être importée en utilisant un espace de noms différent qu’il faudra alors préciser lors de l’appel des différents éléments. Dans l’exemple suivant, la bibliothèque outil est importée sous l’espace de noms « tls ».

01: >>> import outil as tls  

02: >>> print( tls.COULEUR_ROSE )  

03: (255, 153, 204)  

04: >>> print( tls.add(125,15) )  

05: 140  

06: >>> lst = tls.create_list( 4, 0 )  

07: >>> print( lst )  

08: [0, 0, 0, 0]  

09: >>> lst2 = tls.create_list( 3, tls.COULEUR_ROSE )  

10: >>> print( lst2 )  

11: [(255, 153, 204), (255, 153, 204), (255, 153, 204)]

À noter qu’il est également possible d’importer le module sans préciser d’espace de noms (auquel cas, il faudra utiliser le nom du module comme espace de noms).

01: >>> import outil # sans espace de noms 

02: >>> print( outil.COULEUR_ROSE )  

03: (255, 153, 204)  

04: >>> print( outil.add(125,15) )  

05: 140  

06: >>> lst = outil.create_list( 4, 0 )  

07: >>> print( lst )  

08: [0, 0, 0, 0]  

09: >>> lst2 = outil.create_list( 3, outil.COULEUR_ROSE )  

10: >>> print( lst2 )  

11: [(255, 153, 204), (255, 153, 204), (255, 153, 204)]

L’instruction import peut restreindre les éléments importés depuis la bibliothèque en nommant ces derniers dans la commande. Dans l’exemple suivant, la fonction add() et la constante COULEUR_ROSE sont importées. L’exécution de la commande create_list(), non importée, produit donc une erreur d’exécution.

>>> from outil import add, COULEUR_ROSE  

>>> add( COULEUR_ROSE, COULEUR_ROSE )  

(255, 153, 204, 255, 153, 204)  

>>> create_list( 3, COULEUR_ROSE )  

Traceback (most recent call last):  

 File "<stdin>", line 1, in <module>  

NameError: name ’create_list’ is not defined  

>>>

2. Les bibliothèques MicroPython

MicroPython embarque un sous-ensemble de bibliothèques Python, des bibliothèques spécifiques à MicroPython ainsi qu’une bibliothèque propre au module ESP8266.

Les bibliothèques MicroPython n’implémentent qu’une partie des fonctions des bibliothèques standard Python et suivant les ressources disponibles sur la plateforme MicroPython cible, certaines bibliothèques standard pourraient ne pas être incluses dans le firmware MicroPython.

La documentation des différentes bibliothèques est disponible en ligne sur le lien suivant : https://docs.micropython.org/en/latest/esp8266/library/index.html

a. Bibliothèques standards et microbibliothèques

Dans la liste suivante, certaines bibliothèques standards sont préfixées par un « u » (ex. : uos, uzlib...).

Le chargement d’une bibliothèque se fait sans préciser le « u ». Par exemple, uzlib s’importe avec la commande import zlib. Ce préfixe est utilisé par MicroPython pour altérer le processus de chargement d’une bibliothèque, point qui sera abordé plus loin dans le chapitre.

 

Le site documentaire de MicroPython rapporte que certaines implémentations de ussl ne valident pas le certificat serveur. Ce qui ne protège pas les connexions SSL contre les attaques « Man-In-The-Middle ».

b. Bibliothèques spécifiques à MicroPython

Les fonctionnalités spécifiques de la plateforme MicroPython sont reprises dans les bibliothèques suivantes :

c. Bibliothèque spécifique à l’ESP8266

esp est une bibliothèque contenant des fonctions spécifiques à l’ESP8266. Type de veille, mode hibernation, lecture et écriture de la mémoire Flash.

d. Autres bibliothèques MicroPython

Le projet micropython-lib disponible sur GitHub (https://github.com/micropython/micropython-lib) développe une série de bibliothèques standard pour MicroPython. Ce projet GitHub cible MicroPython sur plateforme Unix. Cependant, les bibliothèques n’impliquant aucun échange d’entrée/sortie pourraient également fonctionner sans problème avec les versions embarquées de MicroPython.

e. Mécanisme de chargement d’une bibliothèque

La section précédente met en lumière la présence d’un préfixe « u » sur certaines bibliothèques standards. Ce « u » correspond au signe micro « µ » indiquant que la bibliothèque a été « MicroPythonifiée ». Bien que conçue comme un remplacement du module Python équivalent, une bibliothèque MicroPythonifiée n’implémente que la partie essentielle des fonctionnalités de la bibliothèque Python afin de répondre à la philosophie et aux limitations matérielles (RAM, mémoire Flash) d’une plateforme MicroPython.

Il peut être gênant de modifier les noms des modules Python dans vos scripts développés sur PC ou d’écrire une surcouche chargeant une bibliothèque MicroPythonifiée en lieu et place du nom de bibliothèque standard. Pour répondre à cette problématique, le firmware MicroPython adapte le mécanisme de chargement d’une bibliothèque Python (par exemple import socket) comme suit :

1.

MicroPython recherche la bibliothèque socket dans le système de fichier.

2.

Si la bibliothèque socket existe, alors cette dernière est chargée par le firmware.

3.

Si la bibliothèque n’est pas localisée alors MicroPython recherche la bibliothèque MicroPythonifiée, donc usocket, dans le firmware.

4.

Si la bibliothèque MicroPythonifiée existe dans le firmware, alors cette dernière est chargée. Dans le cas contraire, une exception ImportError est levée.

Ce mécanisme de chargement permet d’étendre les fonctionnalités d’une bibliothèque MicroPythonifiée (uXXX) en écrivant une bibliothèque (XXX.py) stockée dans le système de fichiers. Ce mécanisme permet également de placer sur le système de fichiers une bibliothèque issue de la micropython-lib qui ne serait pas compilée dans firmware MicroPython (note : il faudra enlever le préfixe « u »).  

3. Charger et exécuter un script à la volée

Le script blinky.py ci-dessous est utilisé pour démontrer la fonctionnalité de chargement à la volée. En guise de programme utilisateur, le script fait clignoter la LED rouge attachée sur la broche 0 du Feather Huzzah ESP8266.

Une copie de cet exemple est disponible dans le fichier blinky.py présent dans le répertoire esp8266/divers/ du dépôt GitHub de l’ouvrage.

01: from machine import Pin  

02: from time import sleep  

03: 

04: # Led rouge sur la carte  

05: led = Pin( 0, Pin.OUT )  

06: # Eteindre LED (logique inversée)  

07: led.value( 1 )  

08:  

09: def blink( count = 3 ):  

10:     # clignoter = changer 2x d état  

11:     for i in range( count * 2 ):  

12:         # changer etat de l entrée  

13:         led.value( 0 if led.value()==1 else 1 )  

14:         sleep( 1 )  

15:     # Eteindre la LED  

16:     led.value( 1 )  

17: 

18: # exécution (pour un temps limité) 

19: print( ’Blink x 10’ )  

20: blink( 10 ) 

21: print( ’C est fait’ )

Lignes 5-7 : initialise la broche associée à la LED rouge du Feather ESP8266 Huzzah et maintient une référence vers l’objet Pin dans la variable led. Fait en sorte que la LED reste éteinte. Compte tenu du montage de cette LED sur la carte, cette dernière est éteinte lorsque la broche est au niveau haut et allumée lorsque la broche est au niveau bas.

Ligne 9 : déclaration de la fonction blink() faisant clignoter la LED le nombre de fois mentionné dans le paramètre count.

Ligne 11 : allumer et éteindre la LED nécessite deux opérations d’inversion d’état de la broche associée à la LED. La boucle inversera count x 2 fois l’état de la LED.

Ligne 13 : utilise l’évaluation ternaire 0 if led.value()==1 else 1 pour déterminer l’état inverse par rapport à l’état actuel de la LED. L’expression retourne 0 si led.value() est évalué à 1 sinon l’expression retourne 1. La valeur retournée est donc l’inverse de l’état actuel de la broche. L’instruction dans son ensemble inverse l’état de la broche raccordée à la LED.

Ligne 15 : pause de 1 seconde entre chaque changement d’état.

Ligne 16 : assure que la LED est éteinte en sortie de boucle.

Lignes 19-21 : section de code (de 3 lignes dans le cas présent) exécutée au chargement du fichier blinky.py. Affiche également des messages qui seront visibles dans une session REPL.

Il est maintenant possible de tester l’exécution à la volée après avoir transféré le script blinky.py sur la carte ESP8266.

 Ouvrez une session REPL et saisissez les instructions suivantes :

import blinky

Ce qui produit le résultat suivant :

images/03RI78.pngimages/03RI78.png
 

Résultat de l’importation du fichier blinky

Le résultat ci-dessus démontre clairement l’exécution des lignes 19 à 21 présentes dans le fichier importé.

Voici une méthode intéressante permettant de tester, à la volée, un script en cours de développement sans avoir à saisir une ou plusieurs lignes de code pour initialiser les objets nécessaires à la conduite d’un test (code que l’on retrouve typiquement dans main.py).

Cette opportunité d’importer et exécuter un script à la volée, qui n’est autre qu’une fonctionnalité normale de Python, présente un autre intérêt développé dans la section ci-dessous.

 

Le script blinky, comme n’importe quel autre script, peut être réimporté et réexécuté dans la session REPL en réinitialisant l’interpréteur MicroPython à l’aide de la combinaison de touches [Ctrl] D.

4. RunApp : exécution conditionnelle de main.py

La section Un fichier boot.py pour ESP8266 (cf. Séquence de démarrage MicroPython dans ce chapitre) introduisait le concept de RunApp permettant de reprendre la main sur le système MicroPython lorsque l’exécution ne se déroule pas comme prévu dans boot.py.

Cette même technique peut également être réutilisée pour l’exécution du script principal. Voici de nouveau le montage RunApp où la broche 12 du Feather ESP8266 est branchée sur la masse (GND) par l’intermédiaire d’un interrupteur.

images/03RI75.pngimages/03RI75.png
 

Utilisation d’un interrupteur/cavalier RunApp

À l’instar du fichier boot.py déjà proposé dans cet ouvrage, le fichier main.py peut également lire l’entrée de la broche 12 pour autoriser (ou non) l’exécution du programme principal.

Dans l’exemple ci-dessous, le fichier main.py se propose d’exécuter, à la volée, le script blinky si l’interrupteur RunApp est en position exécution.

01: from machine import Pin 

02: runapp = Pin( 12,  Pin.IN, Pin.PULL_UP )  

03: if runapp.value() == 0: 

04:     print( ’Arret application. RunApp=0’ ) 

05: else: 

06:     import blinky

Une copie de cet exemple est disponible dans le fichier blinky_main.py présent dans le répertoire esp8266/divers/ du dépôt GitHub de l’ouvrage. Fichier à renommer main.py lorsqu’il est copié dans le répertoire racine de l’ESP8266.

La technique RunApp permet de neutraliser l’exécution des scripts (boot.py et main.py) en cas de problème bloquant imprévu.

Placer l’interrupteur sur la position RunApp=0 et presser le bouton « Reset » permet d’instantanément reprendre le contrôle de la plateforme.

 

Il est bien entendu possible de remplacer le contenu de la section « else » par diverses instructions ou l’appel d’une fonction exécutant le corps du programme souhaité en lieu et place d’une importation.

5. Entrées/sorties sur ESP8266

Cette section reprend de nombreux schémas et graphiques. Ceux-ci peuvent être consultés en couleurs sur la version en ligne de l’ouvrage.  

a. Entrée numérique

Une entrée numérique permet de lire l’état logique d’un signal produit par un périphérique externe (microcontrôleur, senseur, bouton, interrupteur, etc.).

Une broche configurée en entrée présente une haute impédance (un peu comme une « haute résistance ») qui fait obstacle au passage du courant. Une broche en entrée peut donc être branchée directement sur une source de tension, dans les limites de la tolérance de l’entrée, sans risquer de provoquer un court-circuit.  

Une entrée numérique d’un ESP8266 est capable de différencier :

 

La région située entre 0,825 V et 2,475 V ne permet pas la détermination du niveau logique d’une entrée. Dans cette région, la lecture de l’entrée numérique produira une valeur logique aléatoire (haut ou bas).

Le montage ci-dessous reprend le raccordement d’un bouton poussoir avec une résistance de rappel à +3,3 V, dite « résistance pull-up ».

images/03RI83.pngimages/03RI83.png
 

Utilisation d’une entrée numérique (avec résistance pull-up externe)

Lorsque le bouton est pressé, son contact fermé connecte la broche 13 directement à la masse (broche GND, donc 0 V). La broche 13 étant à 0 V, le niveau logique de l’entrée est donc « bas ».

Lorsque le bouton est relâché, son contact ouvert libère le potentiel de la broche 13 qui n’est plus raccordée à la masse. La résistance pull-up joue alors son rôle et fixe le potentiel à 3,3 V. La broche 13 est donc au niveau haut.

À noter que lorsque le bouton est pressé, le régulateur de tension est branché à la masse par l’intermédiaire d’une résistance de 10 Kohms. Cela permet d’éviter une situation de court-circuit franc. La résistance limite le courant (entre l’alimentation et la masse) à 3,3 / 10000 = 0,00033 A, soit 0,33 mA.

Les lignes de commandes ci-dessous, saisies dans une session REPL, permettent de lire et d’afficher l’état du bouton.

01: >>> from machine import Pin  

02: >>> from time import sleep 

03: >>> p = Pin( 13, Pin.IN ) 

04: >>> while True:  

05: ...     if p.value() == 1:  

06: ...         print(’PAS presse’)  

07: ...     else:  

08: ...         print(’BOUTON PRESSE’)  

09: ...     sleep( 1 )

Ce qui produit le résultat suivant :

PAS presse  

PAS presse  

BOUTON PRESSE  

BOUTON PRESSE  

BOUTON PRESSE  

PAS presse  

Traceback (most recent call last):  

 File "<stdin>", line 6, in <module>  

KeyboardInterrupt:

 

Presser [Ctrl] C pour interrompre le fonctionnement du script dans la session REPL.

Ligne 1 : importe la classe Pin permettant de manipuler les broches de l’ESP8266.

Ligne 3 : création d’un objet Pin associé à la broche 13. La constante Pin.IN indique que la broche est configurée en entrée (en anglais, IN signifie « entrée »).

Ligne 5 : p.value() permet de lire l’état de la broche et retourne une valeur numérique. La valeur 1 correspond au niveau haut tandis que la valeur 0 correspond au niveau bas. À noter que 1 et 0 sont respectivement évalués aux valeurs logiques True et False. L’utilisation de l’instruction « if p.value() : » aurait donc produit le même résultat.

Ligne 6 : suivant le montage réalisé, le bouton relâché implique un potentiel de 3,3 V sur l’entrée. Cela se traduit par la valeur 1 retournée lors de l’appel de p.value().

b. Entrée numérique (pull-up interne)

La plupart des microcontrôleurs disposent d’une résistance pull-up interne et parfois aussi d’une résistance pull-down. Cette résistance interne est activable à la demande.

L’activation de la résistance pull-up de l’ESP8266 permet de simplifier le montage d’une entrée numérique sans modifier la logique de fonctionnement présentée ci-dessus.

Dans le montage ci-dessous, la résistance pull-up maintiendra la broche au niveau haut (3,3 V) à moins que le potentiel ne soit forcé au niveau bas (la masse, GND) à l’aide du bouton poussoir.

images/03RI84.pngimages/03RI84.png
 

Utilisation d’une entrée numérique (avec résistance pull-up interne)

Exemple de code :

01: >>> from machine import Pin  

02: >>> from time import sleep 

03: >>> p = Pin( 13, Pin.IN, Pin.PULL_UP

04: >>> while True:  

05: ...     print( "---" if p.value() else "BOUTON PRESSE" )  

06: ...     sleep( 1 )

Ce qui produit le résultat suivant :

---  

---  

---  

BOUTON PRESSE  

BOUTON PRESSE  

---  

BOUTON PRESSE  

---  

---  

---

Ligne 3 : lors de la création de l’instance de la classe Pin pour la broche 13, le deuxième paramètre Pin.IN configure la broche en entrée tandis que le troisième paramètre Pin.PULL_UP active la résistance pull-up interne sur la broche.

Ligne 5 : évaluation d’une expression ternaire. Comme déjà précisé, p.value() est évalué à False si la fonction retourne 0 et True dans le cas contraire. L’expression ternaire retourne « --- » si p.value() renvoie 1 (bouton non pressé), sinon la chaîne « BOUTON PRESSE » sera retournée par l’expression ternaire.

c. Entrée numérique et déparasitage logiciel

Lorsque l’on presse le bouton poussoir, le signal ne passe pas instantanément du niveau haut au niveau bas. Le signal présente une phase transitoire pendant laquelle celui-ci passe plusieurs fois d’un niveau logique à l’autre.

images/03RI84a.pngimages/03RI84a.png
 

Transition du signal d’un niveau logique à l’autre

L’effet transitoire ne durant qu’un très bref instant, de l’ordre de la milliseconde, il peut fortement perturber le fonctionnement d’un programme. Les microcontrôleurs sont tellement rapides qu’il est possible de lire plusieurs fois l’entrée durant ce laps de temps.

L’exemple suivant illustre l’impact néfaste de l’effet transitoire sur un simple programme de comptage.

Le script suivant détecte le flanc descendant sur la broche 13 (passage de niveau haut au niveau bas lors de la pression du bouton) et incrémente la valeur du compteur. Il arrive parfois, à cause de l’effet transitoire, que le compteur soit incrémenté plusieurs fois pour une seule pression du bouton. Dans pareil cas de figure, le script détecte plusieurs flancs descendants et flancs montants durant l’effet transitoire.

01: from machine import Pin 

02: compteur = 0  

03: p = Pin( 13, Pin.IN, Pin.PULL_UP )  

04: dernier_etat = p.value()  

05: while True:  

06:     etat = p.value() 

07:     if (etat == 0) and (dernier_etat==1):  

08:         dernier_etat = etat 

09:         compteur += 1  

10:         print( compteur )  

11:     if (etat == 1) and (dernier_etat==0):  

12:         dernier_etat = etat

Lignes 1-3 : import des éléments nécessaires, initialisation du compteur et de la broche 13 en entrée (avec pull-up).

Ligne 4 : initialisation du dernier état connu de la broche d’entrée.

Ligne 5 : boucle infinie (presser [Ctrl] C pour arrêter le script dans une session REPL).

Ligne 6 : capture de l’état de la broche d’entrée.

Ligne 7 : détection de la transition vers le niveau bas. Si l’ancien état connu est niveau haut (1) et état actuel de la broche indique un niveau bas (0), alors le bouton vient d’être pressé.

Ligne 8 : capture du nouvel état (niveau bas) comme dernier état connu afin d’éviter la réactivation de la condition en ligne 7.

Lignes 9-10 : incrémentation de la valeur du compteur et affichage de cette valeur.

Ligne 11 : détection du flan montant (passer d’un niveau bas au niveau haut) lorsque le bouton est relâché.

Ligne 12 : rien à exécuter sur le flan montant donc la seule opération consiste à capturer le dernier état connu.

 

L’exemple ci-dessus est surtout didactique. Il est bien évidemment possible de rendre le script beaucoup plus concis.

Il existe deux approches différentes permettant d’éliminer le phénomène de rebonds :

Le problème du rebond peut être contourné avec la technique de programmation suivante :

1.

Lire l’entrée + détection du changement d’état.

2.

Attendre 10 ms. Temps largement supérieur à la période transitoire, mais bien en dessous du temps nécessaire à un humain pour relâcher le bouton.

3.

Relire l’entrée et s’assurer qu’elle est toujours dans le nouvel état. Si l’état n’est pas resté identique, il s’agit d’un effet transitoire ou d’un parasite. Dans pareil cas, il convient d’ignorer le changement d’état.

Le programme ci-dessous met en œuvre la technique de déparasitage logiciel.

01: from machine import Pin 

02: from time import sleep  

03: compteur = 0  

04: p = Pin( 13, Pin.IN, Pin.PULL_UP )  

05: dernier_etat = p.value()  

06: while True:  

07:     etat = p.value()  

08:     if etat != dernier_etat:  

09:         sleep( 0.010 )  

10:         if p.value() != etat:  

11:             continue  

12:  

13:     if (etat == 0) and (dernier_etat==1):  

14:         dernier_etat = etat  

15:         compteur += 1  

16:         print( compteur )  

17:     if (etat == 1) and (dernier_etat==0):  

18:         dernier_etat = etat

Lignes 1-2 : import des classes et fonctions nécessaires.

Lignes 3-5 : initialisation des variables (dont le dernier état connu de la broche d’entrée).

Ligne 6 : boucle infinie.

Ligne 7 : lecture de l’état de la broche d’entrée.

Ligne 8 : si l’état est différent du dernier état connu, procéder au déparasitage par traitement logiciel.

Ligne 9 : attendre 10 millisecondes avant de relire l’entrée.

Ligne 10 : si la relecture de l’entrée est différente de l’état lu 10 ms plus tôt.

Ligne 11 : alors reprendre l’exécution de la boucle (ligne 06).

Lignes 13-18 : à ce stade, le déparasitage logiciel permet d’être certain du changement d’état de l’entrée. Le restant du script reprend la détection du flan descendant pour effectuer le comptage. 

d. Sortie numérique

Une broche peut également être configurée pour être utilisée comme broche de sortie. Cela permet au microcontrôleur de fixer l’état logique de la broche (niveau haut/niveau bas) afin de piloter d’autres périphériques (relais, LED, etc.).

Dans l’exemple ci-dessous, la broche 15 est utilisée pour contrôler une LED.

images/03RI85.pngimages/03RI85.png
 

Utiliser une sortie numérique

Une broche en sortie voit son potentiel fixé à 3,3 V lorsqu’elle est placée au niveau haut par une instruction. Le potentiel de la broche est de 0 V (ramenée à la masse) lorsque cette dernière est placée au niveau bas. Pour rappel, le courant maximum par broche est de 12 mA. Au-delà, la sortie et/ou le microcontrôleur risquent d’être détruits.

Dans le cas du montage ci-dessus, la broche 15 fournit le courant nécessaire au fonctionnement de la LED. Une LED rouge, jaune ou orange de préférence.

 

Les LED bleues et certaines LED vertes ayant une tension de fonctionnement trop proche de 3,0 V (forward voltage). Il est difficile de les utiliser sur des systèmes en logique 3,3 V.

La sortie 13 est branchée sur la broche la « plus longue » d’une LED (celle correspondant au pôle positif (plus) par l’intermédiaire d’une résistance de 1 Kohms. La résistance limite le courant traversant la LED lorsque la broche est au niveau haut.

Voici quelques commandes permettant de prendre le contrôle de la LED.

01: >>> from machine import Pin  

02: >>> p2 = Pin( 15, Pin.OUT )  

03: >>> p2.value( 1 )  

04: >>> p2.value( 0 )

Ligne 1 : import de la classe Pin permettant de commander une broche.

Ligne 2 : créer une instance de classe Pin pour la broche 15. Le paramètre Pin.OUT indique que la broche sera configurée en sortie.

Ligne 3 : p2.value( 1 ) place la broche au niveau haut. Le potentiel de la broche 15 est donc de 3,3 V, ce qui permet au courant de traverser la LED et de l’allumer.

Ligne 4 : place la broche au niveau bas. Il n’y a donc aucun courant traversant la LED qui reste éteinte.

e. Entrée analogique

Comme déjà précisé, l’unique entrée analogique de l’ESP8266 accepte une tension maximale de 1,0 volt. Dans l’exemple suivant, un potentiomètre de 10 Kohms est utilisé conjointement à une résistance de 26,7 Kohms pour réaliser un pont diviseur de tension produisant une tension de 0 à 0,899 volt.

images/03RI86.pngimages/03RI86.png
 

Lecture analogique

Suivant la théorie des ponts diviseurs de tension, reprise ci-dessous, en utilisant R1 = 26,7K et R2 = 10K (valeur maximale du potentiomètre et produisant donc la tension maximale U2 en sortie du pont diviseur), U2 est la tension appliquée sur le convertisseur ADC, alors que le pont diviseur est alimenté en 3,3 V (U1).

images/03RI86a.pngimages/03RI86a.png
 

Pont diviseur de tension

Pour mémoire, voici le développement du calcul de la tension de sortie du pont diviseur permettant de vérifier que le montage permet de rester dans la zone de tolérance de l’entrée ADC.

U2 = U1 * R2 / (R1 + R2) 

U2 = 3,3V * 10000 / (10000+26700) # 26700 provient de 22K + 4,7K 

U2 = 0,89918

La lecture de l’entrée analogique passe par la classe ADC (Analog to Digital Converter), une classe attachée au convertisseur analogique vers numérique.

 

L’entrée analogique dispose d’une résolution 10 bits, ce qui signifie que le convertisseur retourne une valeur numérique entre 0 et 1024. Valeurs correspondant respectivement à 0 et 1,0 V.

Le script suivant, saisi dans une session REPL, surveille la valeur de l’entrée analogique et affiche chaque modification de celle-ci ainsi que la tension correspondante.

01: from machine import ADC  

02: from time import sleep  

03: adc = ADC( 0 )  

04: val = adc.read()  

05: while True:  

06:     val2 = adc.read()  

07:     if not( val-3 < val2 < val+3 ):  

08:         print( ’Lecture: %s’ % val2 )  

09:         print( ’ * : %s V’ % ((1/1024)*val2) )  

10:         val = val2  

11:     sleep( 0.3 )

Ligne 1 : import de la classe ADC, convertisseur « Analogique vers Numérique ».

Ligne 3 : création de l’objet ADC en précisant le numéro de broche analogique. La broche 0 correspond à la première et unique entrée analogique de l’ESP8266.

Ligne 4 : lecture de la valeur actuelle (dernière valeur connue).

Ligne 5 : boucle infinie. Note : presser [Ctrl] C pour interrompre le script.

Ligne 6 : nouvelle lecture de l’entrée analogique (dans la variable val2).

Ligne 7 : provoque l’exécution des lignes 8 à 10 si la nouvelle valeur est différente de l’ancienne valeur connue (résultat de la modification de la position du potentiomètre). L’expression val-3 <val2 < val+3 compare la nouvelle valeur (val2) par rapport à l’intervalle ± 3 de l’ancienne valeur (val). Elle retourne True si val2 < val+3 et si val2 > val-3. Les lectures analogiques successives d’une entrée analogique peuvent sensiblement osciller autour d’une valeur centrale. Cela est provoqué par d’imperceptibles mouvements du curseur sur la résistance, la tension d’entrée à la limite entre deux valeurs du convertisseur, du bruit en provenance de l’alimentation, la variation de température, etc. C’est la raison pour laquelle le test val2 != val a été écarté car val2 serait presque systématiquement différent de val.

Ligne 8 : affichage de la valeur du convertisseur (valeur entre 0 et 1024).

Ligne 9 : conversion de la valeur numérique en tension. La tension max étant de 1 V pour une valeur retournée allant de 0 à 1024 (résolution 10 bits), chaque unité numérique à 1/1024 volt. La tension analogique est donc égale à lecture_adc * (1/1024).  

Ligne 10 : capture de la valeur lue (val2) comme dernière valeur connue (val).

Ligne 11 : pause de 300 millisecondes entre deux lectures adc.

 

La ligne 9 utilise une double parenthèse dans l’expression ((1/1024)*val2). Si cela n’avait pas été le cas, l’instruction aurait affiché val2 fois le résultat de la division (1/2014). En effet, ’+-’*5 produit la chaîne de caractères ’+-+-+-+-+-’.

L’exécution du script produit le résultat suivant lorsque le bouton du potentiomètre passe de la position minimale à la position maximale :

Lecture: 90  

* : 0.0878906 V  

Lecture: 106  

* : 0.103516 V  

Lecture: 112  

* : 0.109375 V  

Lecture: 223  

* : 0.217773 V  

...  

Lecture: 939  

* : 0.916992 V  

Lecture: 942  

* : 0.919922 V  

Lecture: 939  

* : 0.916992 V

La valeur finale lue sur le convertisseur correspond à 0,916 mV. Ces 17 mV supplémentaires représentent une erreur de 1,89 % par rapport à la valeur max. attendue. Tout à fait acceptable, mais pas étonnant étant donné les 5 % de tolérance des résistances.  

f. Ajout d’entrée/sortie avec MCP23017

Ce qui manque cruellement à l’ESP8266, ce sont des entrées/sorties. Dès lors que le projet prend de l’envergure (ex. : commande de plusieurs relais), il devient rapidement nécessaire de trouver une solution.

Heureusement, il y a le MCP23017 « port expander », composant permettant d’ajouter 16 broches d’entrée/sortie sur un montage. Le MCP23017 est un composant utilisant le bus I2C.

images/03RI87.pngimages/03RI87.png
 

Composant MCP23017 port expander de Microchip

Le bus I2C est un bus d’échange de données utilisant uniquement deux fils, SDA pour le signal de donnée et SCL pour le signal d’horloge. L’architecture d’un bus I2C prévoit un maître (généralement le microcontrôleur, l’ESP8266 dans le cas présent) et des esclaves (senseur I2C et MCP23017) partageant tous les mêmes signaux SDA et SCL.

Le protocole I2C prévoit un mécanisme d’adressage sur 7 bits dans le protocole de communication, ce qui permet à plusieurs esclaves de cohabiter sur le même bus pour autant que chacun d’entre eux dispose d’une adresse unique sur celui-ci.

Par ailleurs, les composants I2C disposent souvent d’une ou plusieurs broches permettant d’altérer l’adresse par défaut du composant. Par exemple, le MCP23017 dispose de 3 broches d’adresse, ce qui permet d’avoir jusqu’à 8 adresses différentes (de 0x20 à 0x27), soit un total de 128 entrées/sorties.

 

Cela ayant de l’importance, le bus transmet les données en faisant passer les signaux au niveau logique bas, ce qui signifie que le bus I2C doit disposer de résistance pull-up ramenant le potentiel des signaux au niveau haut. Suivant la plateforme microcontrôleur et/ou les senseurs utilisés, il est parfois nécessaire de placer ces résistances pull-up sur le montage.

Caractéristiques du MCP23017 :

Dans le montage ci-dessous, le MCP23017 est remplacé par une représentation symbolique facilitant l’identification des différentes broches. Le renfoncement en forme de demi-lune sert de détrompeur, un point permet d’identifier la broche n°1.

L’exemple suivant propose d’utiliser :

Le MCP23017 et l’ESP8266 ne disposant pas de résistances pull-up, le montage prévoit également deux résistances pull-up de 10 Kohms pour ramener le potentiel à des signaux SDA et SCL à 3,3 V.

images/03RI88.pngimages/03RI88.png
 

Raccordement du MCP23017 sur un ESP8266  

L’utilisation du MCP23017 requiert l’installation de la bibliothèque mcp230xx.py disponible sur le dépôt GitHub suivant : https://github.com/mchobby/esp8266-upy/tree/master/mcp230xx

Seul le fichier MCP230xx.py doit être transféré dans le répertoire racine de la plateforme ESP8266.

La fonction scan() permet de détecter la présence du MCP23017 sur le bus I2C en effectuant une opération de scan. Voir l’exemple REPL ci-dessous :

>>> from machine import I2C, Pin 

>>> i2c = I2C( sda=Pin(4), scl=Pin(5) )  

>>> i2c.scan()

Ce qui produit le résultat suivant :

[32]

L’opération retourne une liste d’adresses. Le seul élément est l’adresse 32 en base 10, ce qui correspond à 0x20 (en notation hexadécimale).

Le code suivant explore les possibilités de manipulation des entrées/sorties sur le MCP23017.

La bibliothèque utilise une numérotation GPIO de 0 à 15.

01: >>> from machine import I2C, Pin  

02: >>> i2c = I2C( sda=Pin(4), scl=Pin(5) )  

03: >>>  

04: >>> from mcp230xx import MCP23017  

05: >>> gpios = MCP23017( i2c, address=0x20 )  

06: >>> gpios.setup( 0, Pin.OUT ) 

07: >>> gpios.output( 0, True )  

08: >>> gpios.output( 0, False ) 

09: >>>  

10: >>> gpios.setup( 9, Pin.IN )  

11: >>> gpios.pullup( 9, True )  

12: >>> print( gpios.input( 9 ) )  

13: True  

14: >>> print( gpios.input( 9 ) )  

15: False  

16: >>>

Ligne 1 : import des classes nécessaires.

Ligne 2 : création d’une instance du bus I2C.

Ligne 4 : importation de la classe contrôlant un MCP23017.

Ligne 5 : création d’une instance du composant MCP23017. Passer le bus I2C en paramètre et indiquer l’adresse du composant sur le bus I2C (0x20).

Ligne 6 : configuration de la broche GPIO 0 du MCP23017 en sortie.

Ligne 7 : activation de la sortie (niveau haut, 3,3 volts). La LED s’allume.

Ligne 8 : désactivation de la sortie (niveau bas, 0 volt). La LED est éteinte.

Ligne 10 : configuration de la GPIO 9 du MCP23017 en entrée.

Ligne 11 : activation de la résistance pull-up sur le GPIO 9 du MCP23017.

Lignes 12-13 : affichage de l’état du GPIO 9 du MCP23017. L’état True indique que l’entrée est au niveau haut et donc que l’interrupteur n’est pas fermé.

Lignes 14-15 : affichage, une seconde fois, de l’état du GPIO 9. L’état False indique que l’entrée est au niveau bas et donc que l’interrupteur est fermé.

g. Lecture analogique avec l’ADS1115

Comme déjà précisé, l’unique entrée analogique de l’ESP8266 n’est tolérante qu’à un seul volt, ce qui ne représente pas une gamme de tension confortable pour les makers.

Le convertisseur analogique/numérique ADS1115 permet d’ajouter 4 canaux analogiques (ou 2 entrées analogiques différentielles) pouvant accepter une tension d’alimentation de 2 V à 5,5 V. L’ADS1115 permettra d’effectuer des lectures analogiques jusqu’à 3,3 volts (tension logique de l’ESP8266) avec une précision 16 bits (valeur signée allant de -32768 à +32767). Attention, le signe « - » ne signifie pas pour autant que l’ADS1115 est capable de lire une tension négative !

images/03RI89.pngimages/03RI89.png
 

Breakout ADS1115 (à gauche) et TMP36 senseur de température analogique (à droite)

Le convertisseur ADS1115 utilise le bus I2C pour le transfert des données (voir section précédente), il peut donc être utilisé conjointement avec d’autres composants I2C comme le MCP23017.

Dans cet exemple, le senseur de température analogique TMP36 sera utilisé conjointement avec l’ADS1115 pour illustrer l’utilisation de l’ADC. La sortie analogique du TMP36 propose une tension de sortie proportionnelle à la température mesurée. Cette tension varie de 0,2 à 1,7 volt.

La température se calcule à l’aide de la formule suivante :

Temp en °C = ( Tension-de-sortie-en-milliVolts - 500) / 10

Il est possible d’obtenir plus d’informations sur ces deux produits dans les liens suivants :

Le schéma de raccordement utilise l’entrée A0, premier canal analogique, pour lire la tension de sortie du TMP36. L’adresse de l’ADS1115 sur le bus I2C est 0x48, celle-ci est obtenue en branchant la broche d’adresse ADDR sur la masse.

Pour finir, ADS1115 propose un amplificateur à gain programmable. Une fois un gain activé, il est possible de calculer la valeur en millivolts en multipliant la valeur lue par le multiplicateur repris dans le tableau ci-dessous.

Index du gain

Gamme de tension

Multiplicateur vers millivolts

0

6,144V

0,18750

1

4,096V

0,12500

2

2,048V

0,06250

3

1,024V

0,03125

4

0,512V

0,01562

5

0,256V

0,00781

 

Quelle que soit la gamme de tension applicable, la tension maximale du convertisseur ADC est fixée au niveau logique + 0,3 volt (soit 3,6 V dans le cas présent).

images/03RI90.pngimages/03RI90.png
 

Lecture analogique avec le breakout ADS1115

L’utilisation de l’ADS1115 requiert l’installation de la bibliothèque ads1x15.py disponible sur le dépôt GitHub suivant : https://github.com/mchobby/esp8266-upy/tree/master/ads1015-ads1115

Seul le fichier ads11x15.py doit être transféré sur la plateforme ESP8266.

La session REPL ci-dessous reprend les instructions permettant de lire la valeur du TMP36 branché sur l’entrée A0 du breakout ADS1115.

01: >>> from machine import Pin, I2C  

02: >>> from ads1x15 import *  

03: >>> i2c = I2C( sda=Pin(4), scl=Pin(5) )  

04: >>> adc = ADS1115( i2c, address=0x48, gain=0 )  

05: >>> valeur = adc.read( rate=0, channel1=0 )  

06: >>> mvolts = valeur * 0.1875  

07: >>> print( mvolts )  

08: 723.375  

09: >>> temp = (mvolts - 500)/10  

10: >>> print( "%s deg. Celcius" % temp )  

11: 22.3375 deg. Celcius

Lignes 1-2 : import des classes nécessaires.

Ligne 3 : création du bus I2C.

Ligne 4 : création d’une instance de la classe ADS1115. Fixer le gain à 0 indique que la gamme de tension applicable est de 0 à 6,144 volts. Le multiplicateur est donc 0,1875.

Ligne 5 : lecture de la valeur sur l’entrée A0 (channel1=0). Les autres entrées A1, A2, A3 sont respectivement channel1=1, 2, 3.

Ligne 6 : transformation de la valeur en millivolts.

Lignes 7-8 : affichage de la tension en millivolts.

Ligne 9 : conversion de la tension (en millivolts) vers température en appliquant la formule de conversion du TMP36.

Lignes 10-11 : affichage de la température.

Plus d’informations sur l’utilisation de l’ADS1115 sous MicroPython sont disponibles sur : https://wiki.mchobby.be/index.php?title=FEATHER-MICROPYTHON-ADS1115

6. Senseur et interface sur ESP8266

Cette section reprend, de façon succincte, des capteurs et composants pouvant être exploités avec l’ESP8266 sous MicroPython. Ils pourront être utilisés pour étendre les fonctionnalités du projet proposé dans ce livre.

a. Senseur PIR - senseur de proximité

Le senseur PIR est un senseur infrarouge utilisé pour détecter la présence par détection de mouvements. Le modèle proposé ici est assez similaire à ceux que l’on retrouve dans les systèmes d’alarme à ceci près qu’il est plutôt destiné à la réalisation de projets.

images/03RI91.pngimages/03RI91.png
 

Senseur PIR

Ce modèle embarque déjà une électronique de détection et de traitement qui simplifie l’usage du senseur PIR. Une fois alimenté sous 5 V, le senseur passe le signal de sortie au niveau haut pendant environ 6 secondes lorsqu’un mouvement est détecté à proximité. Ce modèle de senseur propose un signal de sortie en 3,3 V.

 

Il est important de vérifier, à l’aide d’un voltmètre, que le signal de sortie est en 3,3 V (niveau logique compatible avec l’ESP8266). Si le signal de sortie est en 5 V, alors un pont diviseur de tension constitué d’une résistance de 12 Kohms et de 20 Kohms permettent de récupérer un signal de 3,12 volts aux bornes de la résistance de 20 Kohms.

images/03RI92.pngimages/03RI92.png
 

Senseur PIR (signal 3,3 V) branché sur un ESP8266

Il n’est pas indiqué d’activer la résistance pull-up sur l’entrée, comme ce fut le cas pour le bouton poussoir, car le senseur PIR pilote le signal sortie +3,3 V ou 0 V.

Autre particularité du montage : le senseur PIR requiert une tension d’alimentation de 5 V. Cette alimentation est fournie par la broche USB du Feather. Cela implique que le Feather doit être branché en USB pour disposer d’une alimentation 5 V.

Le script suivant surveille l’entrée 13 et indique lorsque le senseur est actif.

01: >>> from machine import Pin  

02: >>> from time import sleep  

03: >>> pir = Pin( 13, Pin.IN )  

04: >>> while True:  

05: >>>   print( ’ACTIF’ if pir.value() else ’.’ )  

06: >>>   sleep(0.750)  

b. Contact magnétique

Le contact magnétique est composé d’un « interrupteur » normalement ouvert qui est maintenu fermé par un aimant généralement placé juste en face du contact. Lorsque l’aimant est éloigné du contact, celui-ci se ré-ouvre.

Le contact magnétique est généralement utilisé pour détecter l’ouverture d’une porte (système d’alarme), mais peut également être utilisé pour détecter l’ouverture d’un tiroir, d’une armoire, d’un réfrigérateur, etc.

Voici un exemple de raccordement semblable à l’utilisation d’un bouton poussoir. La broche 13 sera configurée en entrée avec activation de la résistance pull-up. Lorsque l’aimant est éloigné du contact, ce dernier s’ouvre et la broche 13 est au niveau logique haut. Lorsque l’aimant est proche du contact, celui-ci est fermé et la broche 13 est raccordée à la masse, l’entrée est au niveau logique bas.

images/03RI93.pngimages/03RI93.png
 

Utilisation d’un contact magnétique

Le script suivant permet de surveiller l’entrée 13 pour détecter l’ouverture du contact magnétique. Bien que cela ne soit pas mis en œuvre dans l’exemple, il convient de procéder à un déparasitage logiciel de l’entrée, car comme pour un bouton poussoir, le contact n’est pas franc et instantané.

01: >>> from machine import Pin  

02: >>> from time import sleep  

03: >>> magnetic = Pin( 13, Pin.IN, Pin.PULL_UP )  

04: >>> while True:  

05: >>>     if magnetic.value():  

06: >>>        print( ’PORTE OUVERTE’ )  

07: >>>     else:  

08: >>>        print( ’porte fermee’ )  

09: >>>     sleep( 1 )

c. DHT11 - humidité

Le DHT11 est un senseur de type environnemental abordable permettant de relever le taux d’humidité relative (senseur capacitif) ainsi que la température (thermistance). S’il n’est pas aussi précis que le DHT22 (ou AM2302), la mesure disponible reste néanmoins dans une marge d’erreur acceptable pour l’évaluation sommaire des conditions météorologiques.

 

Il est important de noter que, en raison de la nature même de la constitution du senseur d’humidité, celui-ci se dégrade progressivement durant son utilisation.

images/03RI96.pngimages/03RI96.png
 

Senseur d’humidité et température DHT11

Le DHT11 effectue un relevé de valeur toutes les secondes et émet ensuite les données sur une broche numérique (data out). Étant donné que le senseur n’utilise pas de signal d’horloge durant la transmission d’informations, la bibliothèque doit être capable de capturer et décoder l’information en contrôlant précisément les temps de réception de chacun des bits. Cela rend la famille DHT un peu particulière, mais tout à fait exploitable sur un ESP8266, Arduino et Raspberry Pi. Le DHT11 fonctionne avec une tension d’alimentation de 3 à 5 volts.

Le DHT11 utilise une sortie à collecteur ouvert pour contrôler la broche de données (data out).

images/03RI96b.pngimages/03RI96b.png
 

Cela signifie que le DHT11 à la possibilité de commuter la sortie à la masse pour ramener la tension de la broche de sortie à 0 V. Il est donc nécessaire d’adjoindre une résistance pull-up (souvent de 10 Kohms) pour forcer l’état de la sortie de donnée au niveau logique souhaité (généralement la tension d’alimentation).

Le montage suivant indique comment brancher un DHT11 sur le Feather ESP8266.

images/03RI97.pngimages/03RI97.png
 

Raccordement d’un DHT11 sur le Feather ESP8266

La bibliothèque DHT utilisée dans l’exemple ci-dessous est déjà incluse dans le firmware MicroPython pour ESP8266.

Voici un exemple de code, saisi dans une session REPL, permettant de tester le senseur DHT11.

01: >>> from machine import Pin  

02: >>> from dht import DHT11  

03: >>> d = DHT11( Pin(12) )  

04: >>> d.measure()  

05: >>> d.temperature()  

06: 22  

07: >>> d.humidity()  

08: 37

Ligne 2 : importation de la classe DHT11 (bibliothèque incluse dans le firmware MicroPython). La bibliothèque « dht » inclut également une classe DHT22.

Ligne 3 : créer une instance de la classe DHT11 en indiquant la broche de donnée.

Ligne 4 : synchronisation et lecture des informations sur la broche de données. Si les tentatives de synchronisation échouent alors la fonction lève une exception.

Lignes 5-6 : affichage de la température (en degrés Celsius) acquise durant l’appel de la fonction measure().

Lignes 7-8 : affichage de l’humidité relative (en pourcentage) acquise durant l’appel de la fonction measure().

Vous trouverez plus d’informations sur le DHT11, le DHT22 et un accès à divers tutoriels depuis les liens suivants :

d. Senseur à effet Hall

Le senseur à effet Hall est sensible au champ magnétique. Dans cette catégorie de senseurs, il existe des senseurs analogiques et numériques. Les senseurs analogiques renvoient une tension de sortie en relation avec l’intensité du champ magnétique tandis que les senseurs numériques, comme le US5881LUA, indiquent simplement la présence ou non du champ magnétique.

images/03RI94.pngimages/03RI94.png
 

Senseur à effet Hall et aimant des terres rares

Un senseur à effet Hall numérique est pratique pour réaliser un « interrupteur » à placer dans un emplacement/environnement ne permettant pas l’utilisation d’un interrupteur/détecteur de type mécanique. Il peut être utilisé conjointement avec un flotteur aimanté pour détecter un niveau, un aimant des terres rares pour détecter l’ouverture d’un objet particulier (ex. : le couvercle d’une poubelle), la rotation d’un axe, etc.

Le senseur USS5881LUA fonctionne sous une large gamme de tensions allant de 3,3 V à 24 V. Le senseur utilise une sortie à collecteur ouvert (voir les explications du DHT11 ci-dessus) qui place le signal à la masse lorsque le senseur détecte le champ magnétique d’un pôle sud. Le signal au niveau haut (+3,3 V) est obtenu, en l’absence de champ magnétique, à l’aide d’une résistance pull-up de 10 Kohms branchée sur la broche signal.

images/03RI98.pngimages/03RI98.png
 

Montage d’un senseur à effet Hall numérique sur le Feather ESP8266

Les instructions suivantes saisies dans une session REPL permettent de contrôler le fonctionnement du senseur.

01: >>> from machine import Pin  

02: >>> from time import sleep  

03: >>> p = Pin( 12, Pin.IN )  

04: >>> while True:  

05: >>>     if p.value():  

06: >>>         print(".")  

07: >>>     else:  

08: >>>         print( "AIMANT present" )  

09: >>>     sleep( 1 )

Le senseur s’active lorsque le pôle sud d’un aimant est présenté sur la face avant du senseur (la face portant les inscriptions).

Les instructions produisent un résultat similaire à ceci :

.  

.  

.  

AIMANT present  

AIMANT present  

AIMANT present  

AIMANT present  

.  

.  

AIMANT present  

AIMANT present  

.  

.  

.

e. TSL2561 - luminosité

Le TSL2561 est un senseur I2C permettant de mesurer précisément le niveau de luminosité. Il retourne une valeur en lux allant de 0,1 à 40 000 lux. Un tel senseur est beaucoup plus précis qu’une photorésistance et offre une réponse plus proche de celle de l’œil humain. En effet, la photorésistance ne permet qu’une évaluation grossière de la luminosité (s’il fait jour ou nuit). Les mesures du TSL2561 permettent d’évaluer les conditions de luminosité/ensoleillement, ce qui rend ce composant tout à fait indiqué pour la constitution d’une station météo, d’un projet photo et d’automatisation de jardin.

Voici quelques valeurs typiques de luminosité et leurs correspondances :

L’image ci-dessous présente le breakout TSL2561 produit par Adafruit Industries, breakout qui fonctionne sur une gamme de tensions d’alimentation de 2,7 V à 5 V.

images/03RI99.pngimages/03RI99.png
 

Breakout TSL2561 d’Adafruit Industries

L’adresse par défaut du senseur I2C est 0x39. Elle est obtenue en laissant la broche d’adresse flottante. Cette adresse peut être configurée sur 0x29 en plaçant la broche au niveau bas ou 0x49 en plaçant la broche d’adresse au niveau haut.

L’utilisation de ce senseur implique deux paramètres de configuration importants qui sont :

Le gain x1 permet d’utiliser le senseur dans des conditions de luminosité moyenne à forte tandis que le gain x16 permet de réaliser des mesures en lumière diffuse. À noter que la fonctionnalité « auto-gain » facilite l’utilisation du senseur.

Le temps d’intégration (13 ms, 101 ms, 402 ms) permet de collecter plus ou moins de données sur la luminosité. Plus le temps d’intégration est long et plus précise sera la mesure. Le temps d’intégration de 402 millisecondes est le seul à offrir une résolution de 16 bits, tandis que le temps d’intégration de 13 ms offrira une réponse beaucoup plus rapidement, mais au prix d’une résolution plus faible.

Le montage ci-dessous permet de mesurer la luminosité en utilisant le breakout TSL2561 avec son adresse par défaut 0x39 (broche d’adresse flottante).

images/03RI100.pngimages/03RI100.png
 

Montage du breakout TSL2561

L’utilisation du TSL2561 requiert l’installation de la bibliothèque tsl2561.py disponible sur le dépôt GitHub suivant : https://github.com/mchobby/esp8266-upy/tree/master/tsl2561

Seul le fichier tsl2561.py doit être transféré sur la plateforme ESP8266.

La session REPL, reprise ci-dessous, montre comment exploiter le senseur.

01: >>> from machine import I2C, Pin  

02: >>> from tsl2561 import *  

03: >>> i2c = I2C( sda=Pin(4), scl=Pin(5) )  

04: >>> tsl = TSL2561( i2c )  

05: >>> print( tsl.read() )  

06: 3.01739  

07: >>> print( tsl.read() )  

08: 16.2582  

09: >>> print( tsl.read() )  

10: 3.01739  

11: >>> tsl.gain( 16 )  

12: >>> tsl.integration_time( 402 )  

13: >>> print( tsl.read() )  

14: 3.37176  

15: >>> print( tsl.read( autogain=True ) )  

16: 3.394

Voici quelques explications concernant les différentes commandes utilisées durant la session REPL :

Lignes 1-3 : importation des classes nécessaires et création d’une instance du bus I2C (sur les broches 4 et 5).

Ligne 4 : création d’une instance du senseur TSL2560 (le senseur de luminosité).

Ligne 5 : la méthode read() active le senseur, effectue la mesure et retourne une valeur en lux. Valeur affichée avec l’instruction print().

Ligne 6 : le senseur a retourné une mesure de 3,01 lux. Cela correspond à un milieu sombre (limite crépusculaire au couché du soleil). C’est effectivement le cas étant donné les conditions de luminosité sur le banc d’essai.

Lignes 7-8 : allumer un point d’éclairage néon à proximité du senseur améliore les conditions de luminosité (16 lux) sans pour autant atteindre le niveau de luminosité idéal d’un salon (50 lux).

Lignes 9-10 : retour aux conditions d’éclairage des lignes 5 et 6.

Ligne 11 : en situation de faible luminosité, il est possible d’augmenter le gain du senseur pour obtenir une meilleure précision.

Ligne 12 : augmenter le temps d’intégration à 402 ms afin d’obtenir une mesure plus précise. Ce qui est tout à fait indiqué compte tenu de la faible luminosité ambiante.

Lignes 13-14 : l’augmentation du temps d’intégration et du gain permettent d’affiner la résolution de la mesure (de 3,017 à 3,371 lux).

Ligne 15 : à noter que la méthode read() dispose du paramètre optionnel autogain qui permet de sélectionner automatiquement le gain x1 ou x16 en fonction des conditions de luminosité.

Vous trouverez plus d’informations sur ce senseur dans les tutoriels suivants :

f. BME280 - température, humidité et pression barométrique

Le BME280 de Bosch est un senseur abordable qui se présente comme un véritable couteau suisse de mesures environnementales pour makers. Avec ce seul composant et un peu d’électronique, il est possible de mesurer trois paramètres importants de notre environnement quotidien :

Le BME280 peut s’utiliser aussi bien sur un bus I2C que sur un bus SPI. Adafruit Industrie a monté le BME280 sur un breakout de sorte qu’il est possible de l’utiliser sur des systèmes en logique 3,3 V ou 5 V.

images/03RI101.pngimages/03RI101.png
 

Breakout BME280 d’Adafruit Industries

Le montage suivant indique comment brancher un BME280 sur le Feather ESP8266 en utilisant le bus I2C. Le breakout BME280 est alimenté en 3,3 V depuis la carte Feather via la broche 3 V (derrière le régulateur de tension). Il est également possible d’alimenter la carte de 3,3 V à 5 V via la broche VIN (donc avant le régulateur de tension du breakout).

images/03RI102.pngimages/03RI102.png
 

Montage du breakout BME280

L’utilisation du BME280 requiert l’installation de la bibliothèque bme280.py disponible sur le dépôt GitHub suivant : https://github.com/mchobby/esp8266-upy/tree/master/bme280-bmp280

Seul le fichier bme280.py doit être transféré sur la plateforme ESP8266.

La session REPL, reprise ci-dessous, montre comment exploiter le senseur :

01: >>> from machine import I2C, Pin  

02: >>> from bme280 import *  

03: >>> i2c = I2C( sda=Pin(4), scl=Pin(5) )  

04: >>> bme = BME280( i2c=i2c )  

05: >>> bme.values  

06: (’21.21C’, ’993.88hPa’, ’50.33%’)  

07: >>> bme.raw_values  

08: (20.74, 993.97, 50.79)

Ligne 2 : import de la classe BME280 depuis la bibliothèque bme280.py.

Ligne 3 : création d’une instance du bus I2C.

Ligne 4 : création d’une instance de la classe BME280 (variable bme) en passant le bus I2C en paramètre. La classe BME280 utilisera l’adresse par défaut du senseur (0x76).

Ligne 5 : lecture des valeurs du senseur. values est une propriété de la classe BME280. Celle-ci retourne un tuple Python contenant trois valeurs sous forme de chaînes de caractères prêtes à être affichées.

Ligne 6 : affichage des trois valeurs obtenues via la propriété values dans la session REPL. Soit la température en degrés Celsius, la pression en hectopascal, l’humidité relative en pourcentage.

Ligne 7 : obtention du même tuple de valeurs sous forme de données typées, ce format est plus approprié au traitement de données.

Ligne 8 : affichage des valeurs typées dans la session REPL.

g. Module relais

Le module relais est assurément la méthode la plus simple permettant de contrôler un appareillage haute puissance ou haute tension (comprenez une tension supérieure à 3,3 V). Si les relais sont souvent taxés de gaspilleur d’énergie, ils ont néanmoins l’avantage d’offrir une isolation galvanique qui sépare totalement le circuit haute tension du circuit basse tension.

L’activation d’un relais nécessite une électronique de commande adéquate, raison pour laquelle les modules relais préassemblés sont plus confortables à mettre en œuvre puisqu’ils peuvent être branchés directement sur une sortie du microcontrôleur.

images/03RI103.pngimages/03RI103.png
 

Module bi-relais produit par la société Pololu

 

Compte tenu du nombre limité d’entrées/sorties sur un ESP8266, il est rarement facile d’utiliser plus d’un ou deux relais directement sur le microcontrôleur. En utilisant un MCP23017 (GPIO Expander, présenté ci-avant dans le chapitre), il devient alors possible de commander de très nombreux relais.

Certains éléments doivent retenir l’attention de l’acheteur lorsqu’il sélectionne un module relais destiné à une utilisation sur système en logique 3,3 V. Car la plupart de ces cartes relais nécessitent une tension d’alimentation de 5 V (pour l’activation des relais).

Voici quelques recommandations à destination des néophytes :

Le montage suivant utilise les sorties 13 et 14 pour commander deux relais. La carte relais est alimentée en 5 V par l’intermédiaire de la broche USB fournissant la tension de 5 V et le courant nécessaire à l’activation des relais. L’alimentation est fournie via le connecteur USB. Pour finir, la séparation galvanique permet de commander deux points d’éclairage 12 V en courant continu via le contact normalement ouvert des relais.

images/03RI104.pngimages/03RI104.png
 

Utilisation du module bi-relais sur l’ESP8266

Les instructions suivantes saisies dans une session REPL permettent de contrôler les deux relais indépendamment l’un de l’autre.

01: >>> from machine import Pin  

02: >>> rel1 = Pin( 14, Pin.OUT )  

03: >>> rel2 = Pin( 13, Pin.OUT )  

04: >>> rel1.value( 1 )  

05: >>> rel2.value( 1 )  

06: >>> rel2.value( 0 )  

07: >>> rel1.value( 0 )

Ligne 1 : import de la classe Pin permettant de contrôler les sorties.

Ligne 2 : déclaration du relais rel1 attaché à la broche 14 (voir raccordement). La broche est configurée en sortie avec le paramètre Pin.OUT.

Ligne 3 : déclaration pour le deuxième relais rel2 attaché à la broche 13.

Ligne 4 : activation de la sortie 14, donc du premier relais (broche IN1 du module relais). Ce qui active le relais 1 et allume la lampe L1.

Ligne 5 : activation du second relais et donc de la lampe L2.

 

Si la carte Feather est branchée sur le port USB du Raspberry Pi, ce dernier devra fournir le courant nécessaire au fonctionnement du Feather (interface Wi-Fi) et des relais. Dans ce cas de figure, l’activation du deuxième relais n’est pas toujours probante, car le courant disponible sur l’interface USB du Pi plafonne à 110~130 mA. L’utilisation d’un bloc d’alimentation 5 V avec fiche micro USB permet de palier cette limitation.

Ligne 6 : désactivation du deuxième relais. La lampe L2 s’éteint.

Ligne 7 : désactivation du premier relais. La lampe L1 s’éteint.

Usage en haute tension

Il est important de rappeler que les raccordements réalisés sur les réseaux électriques ayant une tension supérieure à 48 V alternatif (ex. : réseau domestique) ou 24 V continu peuvent être source d’accidents graves.

Par ailleurs, un relais sous-dimensionné en termes de puissance (contrôlant un matériel trop puissant) peut être le point de départ d’un incendie.

Un contact accidentel avec le circuit haute tension peut provoquer de sévères brûlures dans le meilleur des cas et la mort dans les cas les plus graves.

Il est vivement recommandé de s’entourer de personnes ayant les compétences adéquates dès lors que la puissance ou la tension mises en œuvre deviennent importantes !

MQTT sous ESP8266

Interfacer l’ESP8266 avec du matériel est un grand pas dans la réalisation d’objets Internet. Cette section se penche sur le deuxième pilier : la communication.

La section suivante se penche en particulier sur la communication avec le broker MQTT.

Pour rappel, le broker MQTT est installé sur le Raspberry Pi avec la configuration suivante :

1. Publication MQTT sous MicroPython

Le script d’exemple mqtt_pub.py détaillé ci-dessous effectue une série de publications sur le broker MQTT.

Une copie de cet exemple est disponible dans le répertoire esp8266/divers/ du dépôt GitHub de l’ouvrage. Le fichier pourra être téléversé sur la plateforme à l’aide d’un outil tel que RShell ou Ampy.

01: """ La Maison Pythonic - publication sur broker MQTT depuis  

02:      MicroPython """  

03: import time  

04: from network import WLAN  

05: from umqtt.simple import MQTTClient  

06: from ubinascii import hexlify  

07: import sys  

08:  

09: CLIENT_ID = "demo-pub"  

10:  

11: MQTT_SERVER = "192.168.1.210"  

12:  

13: # Mettre a None si pas utile  

14: MQTT_USER = ’pusr103’  

15: MQTT_PSWD = ’21052017’  

16:  

17: print( "Creation MQTTClient")  

18: q = MQTTClient( client_id = CLIENT_ID, server =  

19:      MQTT_SERVER, user = MQTT_USER, password = MQTT_PSWD )  

20:  

21: if q.connect() != 0:  

22:     print( "erreur connexion" )  

23:     sys.exit()  

24: print( "Connecté" )  

25:  

26: # annonce connexion objet  

27: sMac = hexlify( WLAN().config( ’mac’ ) ).decode()  

28: q.publish( "connect/%s" % CLIENT_ID , sMac )  

29:  

30: # publication d’un compteur  

31: for i in range( 10 ):  

32:     print( "pub %s" % i )  

33:     q.publish( "demo/compteur", str(i) )  

34:     time.sleep( 1 )  

35:  

36: q.disconnect()  

37: print( "Fin de traitement" )

Ligne 5 : importation du client MQTT.

Ligne 9 : identification du client MQTT (permet d’identifier l’objet internet).

Ligne 11 : adresse IP du broker MQTT sur le réseau local. Peut également être un nom de domaine comme test.mosquitto.org ou un nom d’hôte sur le réseau local comme pythonic.local (voir ci-dessous la note concernant la résolution DNS).

Lignes 14-15 : identification utilisateur demandés pour établir la connexion avec le broker MQTT. Ces paramètres peuvent être à None s’ils ne sont pas nécessaires, ce qui serait le cas sur le broker MQTT de test de Mosquitto (test.mosquitto.org).

Ligne 18 : création d’une instance de la classe MQTTClient en passant les paramètres de connexion.

Ligne 21 : établissement de la connexion avec le broker.

Ligne 23 : fin d’exécution du script en cas d’erreur de connexion.

Ligne 27 : transformation de l’adresse MAC de l’ESP en chaîne de caractères unicode. La fonction hexlify() transforme une chaîne de caractères en sa représentation hexadécimale. hexlify( "hello" ) produit b’68656c6c6f’. La fonction hexlify() retourne un type bytes (un tableau d’octets) qu’il faut transformer en chaîne de caractères unicode avant de réaliser une publication MQTT. La fonction decode() permet de transformer le type bytes en chaîne unicode. type( b’68656c6c6f’.decode() )’ renvoie <class ’str’>.

Ligne 28 : publication de l’adresse MAC sur le topic connect/demo-pub (demo-pub étant le ClientId de l’objet Internet). Cette approche est utilisée pour communiquer la connexion de l’objet IoT sur le broker. L’utilisation de l’adresse MAC dans le message permet d’identifier chaque objet sans équivoque (puisque les adresses MAC sont uniques).

Lignes 31-33 : utilisation d’une boucle for pour publier 10 messages (valeurs de 0 à 9) sur le topic demo/compteur. L’instruction time.sleep(1) permet d’éviter l’envoi des messages en rafale.

Ligne 36 : déconnexion du client MQTT.

Tester la publication

Les publications pourront être consultées à l’aide de l’utilitaire mosquitto_sub réalisant une souscription sur la totalité des topics à l’aide du filtre « # ». La souscription doit, bien entendu, avoir pris place avant l’exécution du script MicroPython.

mosquitto_sub -h pythonic.local -t "#" -u "pusr103" -P "21052017" -v

Après avoir réalisé la souscription, une session REPL (ouverte depuis un autre terminal) permet de lancer le script de test à l’aide de import mqtt_py.

images/03RI106.pngimages/03RI106.png
 

Script MicroPython effectuant des publications

Comme le montre la capture ci-dessous, l’utilitaire mosquitto_sub permet de constater la publication des différents messages du script MicroPython. L’option -V affiche également le topic sur lequel le message est publié.

images/03RI105.PNGimages/03RI105.PNG
 

mosquitto_sub, affichage des messages publiés sur le broker MQTT

À propos de la résolution DNS

Le script Python inclut la définition de la constante MQTT_SERVER permettant d’identifier le broker MQTT sur lequel le client doit se connecter.

Dans cet exemple, l’adresse IP du broker est utilisée car les tests sont conduits sur un réseau domestique géré par le modem-routeur du fournisseur d’accès Internet.

MicroPython n’implémente pas le protocole mDns facilitant la résolution du nom d’hôte sur un réseau domestique (ex. : MQTT_SERVER = "pythonic.local") vers une adresse IP. En conséquence, le succès de cette résolution repose intégralement sur la capacité du modem-routeur à capturer la correspondance adresses IP <-> nom d’hôtes.

L’expérience a démontré que cette résolution n’est pas fiable sur tous les modems-routeurs, ni stable dans le temps. En conséquence, le Raspberry Pi sur lequel est installé le broker MQTT a été configuré avec une adresse IP fixe (192.168.1.210) pour contourner les problèmes de fiabilité de résolution DNS sur le réseau domestique.

2. Souscription MQTT sous MicroPython

Le script d’exemple mqtt_sub.py détaillé ci-dessous réalise une souscription demo/# pour capturer tous les topics sous demo. Les messages sont affichés dans la session REPL. Le topic cmd/led permet de commander la LED raccordée sur la broche 0 avec les messages on et off.

Une copie de cet exemple est disponible dans le répertoire esp8266/divers/ du dépôt GitHub de l’ouvrage. Le fichier pourra être téléversé sur la plateforme à l’aide d’un outil tel que RShell ou Ampy.

01: """ La Maison Pythonic - souscription sur broker MQTT  

02:      depuis MicroPython """  

03: import time  

04: from network import WLAN  

05: from machine import Pin  

06: from umqtt.simple import MQTTClient  

07: from ubinascii import hexlify  

08: import sys  

09:  

10: CLIENT_ID = "demo-sub"  

11:  

12: MQTT_SERVER = "192.168.1.210"  

13:  

14: # Mettre a None si pas utile  

15: MQTT_USER = ’pusr103’  

16: MQTT_PSWD = ’21052017’  

17:  

18: led = Pin( 0, Pin.OUT )  

19: led.value( 1 ) # eteindre  

20:  

21: def sub_cb( topic, msg ):  

22:     """ fonction de rappel pour souscription MQTT """  

23:     t = topic.decode(’utf8’)  

24:     m = msg.decode(’utf8’)  

25:     print( ’-’*20 )  

26:     print( ’topic: %s’ % t )  

27:     print( ’message: %s’ % m )  

28:  

29:     if t == ’cmd/led’:  

30:         print( "changement etat led")  

31:         # LED en logique inversée  

32:         led.value( 1 if m=="off" else 0 )  

33:  

34: print( "Création MQTTClient")  

35: q = MQTTClient( client_id = CLIENT_ID, server =  

36:      MQTT_SERVER, user = MQTT_USER, password = MQTT_PSWD )  

37: q.set_callback( sub_cb )  

38:  

39: if q.connect() != 0:  

40:     print( "erreur connexion" )  

41:     sys.exit()  

42: print( "Connecté" )  

43:  

44: q.subscribe( ’demo/#’ )  

45: q.subscribe( ’cmd/led’ )  

46: print( "souscription OK" )  

47:  

48: # annonce connexion objet  

49: sMac = hexlify( WLAN().config( ’mac’ ) ).decode()  

50: q.publish( "connect/%s" % CLIENT_ID , sMac )  

51:  

52: # Boucle de traitement  

53: while True:  

54:     # traitement message MQTT (BLOQUANT)  

55:     q.wait_msg()  

56:  

57:     # traitement message MQTT (NON BLOQUANT)  

58:     # q.check_msg()  

59:  

60: q.disconnect()

Lignes 3-8 : importation des bibliothèques nécessaires.

Lignes 10-16 : définition des constantes utiles. CLIENT_ID est utilisé pour identifier l’objet Internet. MQTT_SERVER identifie le serveur MQTT à contacter, voir la note concernant la résolution DNS dans la section précédente. MQTT_USER et MQTT_PSWD sont les login et mot de passe autorisant la connexion sur le broker MQTT. Ces deux dernières valeurs peuvent être à None si le broker autorise les connexions anonymes.

Lignes 18-19 : activation de la broche #0 en sortie, broche sur laquelle est branchée la LED rouge du Feather ESP8266. Cette LED fonctionne en logique inverse (cf. Broches d’entrée/sortie dans ce chapitre). La sortie est placée au niveau haut pour éteindre la LED.

Lignes 21-32 : définition de la fonction de rappel sub_cb() qui sera appelée par MQTTClient lorsqu’un message est reçu par le broker. Les détails de la fonction sub_cb() sont abordés un peu plus loin.

Ligne 35 : création d’une instance de l’objet MQTTClient avec tous les paramètres nécessaires à la connexion. À ce stade, la connexion n’est pas encore établie avec le broker MQTT.

Ligne 37 : assignation de la fonction de rappel sub_cb() sur le client MQTTClient. Cette assignation doit se faire avant la connexion au broker.  

Lignes 39-41 : connexion sur le broker MQTT. Si la connexion est refusée, alors l’exécution du script est interrompue à l’aide de sys.exit().

Ligne 44 : souscription à tous les sous-topics de « demo » (à l’aide de l’expression de filtrage demo/#). Note : la souscription demo/+ permettrait de capturer un seul sous-niveau.

Ligne 45 : souscription à un topic donné demo/led. La réception des messages « on » et « off » sur ce topic permettra de commander la LED branchée sur la broche #0. Voir détail de la fonction de rappel sub_cb() ci-dessous.

Lignes 49-50 : annonce de la connexion de l’objet sur le broker MQTT. Le détail de ces lignes est abordé dans le chapitre précédent concernant la publication de messages.

Lignes 53-55 : boucle de traitement des messages MQTT. L’instruction while True : assure une boucle de traitement infinie. L’instruction q.wait_msg() attend qu’un message correspondant aux souscriptions soit communiqué par le broker. Une fois celui-ci reçu, la fonction de rappel sub_cb() est appelée pour traiter le message.

Ligne 60 : déconnexion du broker (pour information). Les messages étant traités dans une boucle infinie (voir ligne 53), cette ligne ne sera jamais exécutée.

Les méthodes wait_msg() et check_msg()

La méthode wait_msg() de la classe MQTTClient permet à MQTTClient de traiter un message (et un seul) en provenance du broker MQTT. Message distribué au client MQTT suite à une ou plusieurs souscriptions (voir lignes 44 et 45) effectuées sur le broker.

Lorsque le message est reçu, wait_msg() passe le relais à la fonction de rappel utilisateur sub_cb() assignée à l’aide de l’instruction q.set_callback(sub_cb). La fonction de rappel, définie dans le script utilisateur, est la seule responsable du traitement des messages reçus depuis le broker MQTT.

Il est important de noter que la méthode wait_msg() est bloquante ! Cette dernière ne rend la main au script principal qu’après avoir reçu un message du broker. S’il n’y a pas de réception de message avant une heure, alors la fonction wait_msg() bloque l’exécution du script pendant une heure. L’utilisation de la méthode wait_msg() est parfaite si l’objet en cours de développement n’a pas d’autres tâches à réaliser que d’attendre les messages entrants.

Si l’objet doit également communiquer des informations à intervalle régulier (publication de la température), alors la méthode check_msg() sera utilisée à la place de wait_msg().

La méthode check_msg() est non bloquante. Si le message est présent, il est traité comme le fait la fonction wait_msg(). S’il n’y a pas de message en attente, la méthode rend la main immédiatement au script principal.

 

Un maximum de un message est traité par appel de wait_msg() ou check_msg(). Par conséquent, la rapidité de traitement des messages entrants dépend (1) de la durée de traitement de la fonction de rappel sub_cb() et (2) du temps nécessaire au script principal pour faire l’appel suivant à wait_msg() / check_msg().

La fonction de rappel sub_cb()

La fonction de rappel est appelée par wait_msg() ou check_msg() lorsqu’un message entrant doit être traité par le client MQTT.

La fonction de rappel de l’exemple ci-dessous est appelée avec deux paramètres : le topic et le message.

def sub_cb( topic, msg ):  

   ...

Cette fonction de rappel est l’unique point d’entrée pour toutes les souscriptions. Il faut donc tester la valeur du paramètre topic pour exécuter le traitement adéquat en fonction du topic et du message reçu.

À noter que ces paramètres sont des chaînes d’octets (type bytes).

21: def sub_cb( topic, msg ):  

22:     """ fonction de rappel pour souscription MQTT """  

23:     t = topic.decode(’utf8’)  

24:     m = msg.decode(’utf8’)  

25:     print( ’-’*20 )  

26:     print( ’topic: %s’ % t )  

27:     print( ’message: %s’ % m )  

28:  

29:     if t == ’cmd/led’:  

30:         print( "changement etat led")  

31:         # LED en logique inversée  

32:         led.value( 1 if m=="off" else 0 )

Ligne 21 : déclaration de la fonction de rappel avec les deux paramètres transmis par MQTTClient. Le nom de la fonction sub_cb correspond à « subcriber callback ».

Ligne 23 : le paramètre topic est de type bytes (une chaîne d’octets) qu’il faut transformer en chaîne de caractères pour permettre la comparaison avec une autre chaîne de caractères. C’est ce que fait la fonction decode().

Ligne 24 : conversion du message du type bytes vers type str.

Lignes 25-27 : affichage du topic et du message dans la console REPL pour faciliter le débogage.

Ligne 29 : branchement pour le traitement spécifique des messages du topic cmd/led.

Ligne 32 : modification de l’état de la broche #0 qui contrôle la LED branchée en logique inverse. L’expression ternaire 1 if m=="off" else 0 retourne la valeur 1 (donc éteint la LED) si le message contient « off », sinon retourne la valeur 0 (donc allume la LED).

Tester la souscription

La souscription peut être testée sur l’ESP8266 en saisissant la ligne de commande import mqtt_sub depuis une session REPL. La session REPL permettra la capture des différents messages produits par les fonctions print().

L’utilisation d’un second terminal sur le Raspberry Pi permet de publier différents messages sur le broker MQTT comme indiqué dans la capture suivante :

images/03RI107.PNGimages/03RI107.PNG
 

Publication manuelle de messages sur le broker MQTT

Le résultat des différentes publications peut être constaté dans la session REPL MicroPython visible dans la capture ci-dessous.

images/03RI108.PNGimages/03RI108.PNG
 

Session REPL affichant les messages reçus par la fonction de rappel

Le test met en évidence :

Asyncio sur ESP8266

Un sous-ensemble de Asyncio est disponible dans le firmware MicroPython depuis la release 1.9.3 (cf. module uasynio).

Asyncio sera utilisé dans le présent projet pour réaliser un planificateur de tâches (scheduler) en vue d’exécuter des opérations à intervalles réguliers. Celles-ci sont aussi variées que :

1. Asyncio en quelques mots

Asyncio permet d’exécuter plusieurs sections de code (des fonctions, dites « coroutines ») de manière asynchrone. Quand une section de code termine (ou suspend) son exécution, alors Asyncio peut passer la main à une autre section de code à exécuter.

Attention, il ne s’agit pas de traitement multitâche en parallèle. La boucle d’exécution d’Asyncio utilise un seul et unique processus pour gérer l’exécution, l’une après l’autre, des sections de codes. Elle permet un traitement des sections de code sans blocage en utilisant un mode coopératif.

Étant donné que cette boucle d’exécution et les sections de codes fonctionnent dans un seul et même processus, cela implique que toutes ces sections de code partagent le même espace mémoire. Les variables globales sont donc accessibles par toutes les fonctions. De même, une section de code modifiant le contenu d’une variable globale ne risque pas d’entrer en conflit avec une autre puisqu’il n’y a qu’une seule section de code exécutée à un moment donné.

L’utilisation d’Asyncio n’améliore pas les temps d’exécution, car il n’y a pas de traitement parallèle des tâches. Par contre, Asyncio simplifie le développement de scripts mettant en œuvre diverses tâches asynchrones, développement qui serait nettement plus difficile à réaliser avec des méthodes de programmation de base.  

Asyncio permet un traitement multitâche de type coopératif pour un très faible coût en termes de ressources, ce qui est idéal pour un environnement d’exécution à base de microcontrôleur.

2. Asyncio par l’exemple

Le script d’exemple scheduler_asyncio.py détaillé ci-dessous met en évidence le traitement de plusieurs opérations en mode coopératif en vue de réaliser un planificateur de tâches, qui affichent des messages à intervalle régulier. La première affiche un message toutes les secondes tandis que la deuxième affiche un autre toutes les 1,2 secondes.

Une copie de cet exemple est disponible dans le répertoire esp8266/divers/ du dépôt GitHub de l’ouvrage. Le fichier pourra être téléversé sur la plateforme à l’aide d’un outil tel que RShell ou Ampy. À noter qu’il sera nécessaire de renommer le fichier durant la copie afin que son nom ne dépasse pas 8 caractères.

01: # coding: utf8  

02: """ Utilisation de asyncio pour planifier des taches.  

03:  

04:   Voir également asyncio et exemples  

05:   https://github.com/peterhinch/micropython-async """  

06:  

07: import uasyncio as asyncio  

08:  

09: async def print_this( s, time_ms ):  

10:     while True:  

11:         await asyncio.sleep_ms( time_ms )  

12:         print( s )  

13:  

14: loop = asyncio.get_event_loop()  

15: loop.create_task( print_this( "every sec", 1000 ) )  

16: loop.create_task( print_this( "every 1.2sec", 1200 ) )  

17:  

18: loop.run_forever()  

19:  

20: #async def killer( sec ):  

21: #    await asyncio.sleep( sec )  

22: #  

23: #loop.run_until_complete( killer( sec=25 ) )  

24: #print( "Execution terminee")  

25:  

26: loop.close()

Ligne 7 : importe la microbibliothèque uasyncio sous l’espace de noms asyncio.

Ligne 9 : définition d’une fonction asynchrone (une coroutine) qui sera appelée par la boucle d’exécution asyncio. La fonction prend en paramètre le message à afficher et le temps de pause (en millisecondes) entre deux affichages consécutifs.

Ligne 10 : boucle infinie, cette fonction exécute le bloc d’instructions contenu de façon répétitive.

Ligne 11 : faire une pause d’une durée de time_ms millisecondes en rendant la main à la boucle de traitement asyncio. De la sorte, d’autres tâches ont éventuellement l’opportunité d’être exécutées. Au terme du délai d’attente, la boucle de traitement asyncio continue l’exécution de la fonction et passe à la ligne 12.

Ligne 12 : exécution de la partie « tâche » de la fonction, symbolisée par une instruction print().

Ligne 14 : création de la boucle de traitement asyncio.

Lignes 15-16 : création de deux tâches distinctes utilisant la fonction asynchrone print_this(). La fonction asynchrone print_this() sera donc exécutée deux fois, à tour de rôle, avec des contextes différents pour chaque tâche.

Ligne 18 : exécution continue de la boucle de traitement asyncio sans condition de sortie.

Lignes 20-24 : lignes en commentaires et non exécutées dans cette version du script (voir plus loin).

Ligne 26 : libération des ressources asyncio. Techniquement, cette ligne n’est pas exécutée dans cette version du script puisque la ligne 18 s’exécute indéfiniment.

Tester le script d’exemple

Après avoir copié le script scheduler_asyncio.py sur la plateforme MicroPython en ayant pris soin de le renommer aio_demo.py (maximum 8 caractères), il est possible d’en tester le contenu dans une session REPL à l’aide de l’instruction import aio_demo. Presser [Ctrl] C pour interrompre l’exécution du script.

L’exécution du script produit le résultat suivant :

images/03RI109.pngimages/03RI109.png
 

Session REPL affichant le résultat de aio_demo.py

Le résultat démontre que les 200 ms supplémentaires de la deuxième tâche s’accumulent et portent à conséquence au bout de 5 itérations (voir la succession des deux messages « every sec » successifs).

Condition de sortie

La boucle de traitement asyncio peut être interrompue avec une condition de sortie en remplaçantl’appel de loop.run_forever() par loop.run_until_complete(fct_de_ test). La boucle de traitement asyncio termine son exécution dès lors que la fonction de test fct_de_test() achève son traitement. Cette fonctionnalité est très pratique pour interrompre le traitement du script en fonction de la position d’un interrupteur (cf. Séquence de démarrage MicroPython - RunApp - Activation de l’application dans ce chapitre).

La fonction de test est également une fonction asynchrone comme le démontre l’exemple utilisant la fonction killer().

Dans le code de scheduler_asyncio.py, remplacez la section code suivant :

18: loop.run_forever()  

19:  

20: #async def killer( sec ):  

21: #    await asyncio.sleep( sec )  

22: #  

23: #loop.run_until_complete( killer( sec=25 ) )  

24: #print( "Execution terminee")  

25:  

26: loop.close()

par

18: #loop.run_forever()  

19:  

20: async def killer( sec ):  

21:     await asyncio.sleep( sec )  

22:  

23: loop.run_until_complete( killer( sec=25 ) )  

24: print( "Execution terminee")  

25:  

26: loop.close()

Lignes 20-21 : définition de la fonction asynchrone killer() qui attend x secondes avant de terminer son exécution. Le temps de pause rend la main à la boucle de traitement asyncio.

Ligne 23 : exécution de la boucle de traitement asyncio. Boucle qui s’achèvera lorsque la fonction killer( sec=25 ) termine son traitement. Pour rappel, cette fonction fait une simple pause de 25 secondes !

Ligne 24 : affichage d’un message signalant la fin d’exécution de la bouche de traitement.

Ligne 26 : libération des ressources asyncio.

Tester le script modifié

Après avoir modifié et copié le script scheduler_asyncio.py sur la plateforme MicroPython en ayant pris soin de le renommer aio_demo.py (maximum 8 caractères), il est possible d’en tester le contenu dans une session REPL à l’aide de l’instruction import aio_demo. Cette fois, il n’est pas nécessaire de terminer l’exécution du script, celui-ci s’interrompra tout seul au bout de 25 secondes.

L’exécution du script produit le résultat suivant :

images/03RI110.pngimages/03RI110.png
 

Session REPL affichant le résultat de aio_demo.py

3. Fonction run_every pour Asyncio

Le projet développe différents objets internet dont chacun publie des informations à intervalle régulier sur le broker MQTT. Asyncio est tout indiqué pour le traitement de ces tâches répétitives.

L’utilisation d’une fonction asynchrone run_every() facilite grandement la création de tâches répétitives avec Asyncio.

Voici un extrait du code d’un objet dans lequel est déclaré la fonction run_every(). Cette fonction met en œuvre une boucle infinie dont la seule tâche est d’appeler une fonction de traitement nommée fn à intervalle régulier. La fonction de traitement fn est communiquée en paramètre, cette dernière implémente le code utile de la tâche à exécuter à intervalle régulier.

À noter que la fonction run_every() éteint la LED sur la broche #0 pendant l’exécution de la fonction fn. Cela permet d’informer un éventuel utilisateur qu’une tâche est en cours d’exécution.  

01 : async def run_every( fn, min= 1, sec=None):  

02:   """ Exécute une fonction fn toutes les minutes ou  

         secondes"""  

03:   global led  

04:   wait_sec = sec if sec else min*60  

05:   while True:  

06:      led.value( 1 )  

07:      try:  

08:         fn()  

09:      except Exception:  

10:         print( "run_every catch exception for %s" % fn)  

11:         raise # quitter boucle  

12:      led.value( 0 )  

13:      await asyncio.sleep( wait_sec )

Ligne 1 : définition de la fonction. Le paramètre fn contient une référence vers la fonction à appeler à intervalle régulier. Le paramètre min indique le temps de pause en minutes entre deux appels de la fonction fn. Si défini, le paramètre sec indique le temps de pause en seconde et sera prévalent sur le paramètre min.

Ligne 3 : permet d’avoir accès à la variable globale led. Cette dernière permet de contrôler la LED branchée sur la broche #0 du Feather ESP8266.

Ligne 4 : calcul du temps de pause en secondes depuis le paramètre sec si il est défini, sinon c’est le paramètre min (minutes) qui sera utilisé.

Ligne 5 : boucle infinie.

Ligne 6 : éteint la LED (logique inverse).

Ligne 8 : exécution de la fonction indiquée par le paramètre fn. Les parenthèses derrière fn() permettent d’exécuter la fonction en question. La fonction est appelée sans paramètre.

Lignes 7-9-11 : capture de toutes les exceptions durant l’exécution de la fonction fn. La ligne 10 affiche une représentation de la fonction (le nom de la fonction défini dans le script) ayant produit l’erreur de sorte que cette information est visible sur une console REPL. La ligne 11 relance l’exception d’origine pour qu’elle atteigne la boucle de traitement asyncio.

Ligne 12 : rallume la LED branchée sur la broche #0 après exécution de la fonction de rappel fn.

Ligne 13 : temps de pause entre deux appels consécutifs de fn.

Lors de la création d’une tâche, la fonction run_every() s’utilise comme suit :

01: loop = asyncio.get_event_loop()  

02: loop.create_task( run_every(capture_1h, min=60) )  

03: loop.create_task( run_every(pir_alert, sec=10) )  

04: loop.create_task( run_every(pir_update, min=5))  

05: loop.create_task( run_every(heartbeat, sec=10) )  

06: try:  

07:    loop.run_until_complete( run_app_exit() )  

08: except Exception as e :  

09:    print( e )  

10:    led_error( step=6 )

Ligne 1 : création de la boucle de traitement asyncio.

Ligne 2 : création d’une tâche répétitive (toutes les 60 minutes) qui appelle la fonction capture_1h(). Cette fonction, exécutée toutes les heures, est responsable de la capture de paramètres environnementaux et de leur publication sur le broker MQTT.

Lignes 3-5 : création d’autres tâches répétitives appelant d’autres fonctions.

Ligne 7 : exécution de la boucle de traitement asyncio jusqu’à la fin d’exécution de la fonction run_app_exit(). La fonction run_app_exit() (détaillée ci-dessous) teste l’état de l’interrupteur RunApp (broche #12) toutes les 10 secondes. La fonction termine son traitement lorsque RunApp est placé sur la position arrêt.

Lignes 6-8-10 : capture de toutes les exceptions pouvant se produire durant le traitement des tâches asynchrones. La ligne 9 affiche le message d’exception dans une session REPL. La ligne 10 appelle la fonction led_error() responsable de l’affichage d’un code d’erreur sur la LED de la broche #0 et du redémarrage de l’objet (cf. Les objets ESP8266 - LED de statut).

Détails de la fonction run_app_exit() :

01: runapp = Pin( 12,  Pin.IN, Pin.PULL_UP )  

02:  

03: async def run_app_exit():  

04:     """ fin d’exécution lorsque quitte la fonction """  

05:     global runapp  

06:     while runapp.value()==1:  

07:         await asyncio.sleep( 10 )  

08:     return

4. Plus d’informations sur Asyncio

L’utilisation de Asyncio va bien au-delà de la création d’un planificateur de tâche, ces fonctionnalités sortent du cadre de l’ouvrage.

De nombreuses informations et exemples concernant Asyncio pour MicroPython sont disponibles sur le dépôt GitHub : https://github.com/peterhinch/micropython-async

Informations pratiques

1. Prérequis et configurations

Ce chapitre se concentre sur le développement des objets IoT et nécessite quelques prérequis abordés dans les précédents chapitres avant de se lancer dans l’aventure.

1.

Les ESP8266 sont reflashés avec MicroPython pour ESP8266 (version 1.9.1 minimum, elle apporte le support de uasyncio utilisé dans les objets), cf. ESP8266 sous MicroPython - Charger le firmware MicroPython.

2.

La copie de scripts Python sur l’ESP8266 à l’aide d’un utilitaire comme RShell (ou équivalent) est un point maîtrisé, cf. ESP8266 sous MicroPython - Prise de contrôle.

3.

La mise en place du fichier boot.py avec authentification sur le réseau Wi-Fi domestique ainsi que la fonctionnalité RunApp, cf. ESP8266 sous MicroPython - Séquence de démarrage MicroPython.

4.

Le broker MQTT Eclipse Mosquitto est installé sur le Raspberry Pi et configuré avec une authentification avec le login pusr103 et le mot de passe 21052017, cf. Le broker MQTT - Installation de Mosquitto, cf. Le broker MQTT - Configurer le login du broker MQTT.

5.

Le Raspberry Pi exécutant le broker MQTT est configuré avec l’adresse IP fixe 192.168.1.210 dans le cadre de cet ouvrage. Toute modification d’adresse IP du Raspberry Pi implique une modification des scripts Python avant de les téléverser sur les objets.

2. LED de statut

Un élément important de tout projet est la possibilité d’informer l’utilisateur sur son état de fonctionnement. La LED #0 disponible sur le feather ESP8266 est utilisée pour indiquer ce statut. 

images/04RI01.pngimages/04RI01.png
 

Utilisation de la LED #0 comme LED de statut

La LED de statut utilise plusieurs motifs de clignotement pour informer l’utilisateur sur le fonctionnement interne de la plateforme.

Motif de la LED

Description

Éteinte

À l’arrêt. Vérifier interrupteur RunApp (ou le fichier boot.py) puis presser Reset pour redémarrer.

À noter que la LED est également éteinte après l’arrêt de l’objet.

Allumée (après démarrage)

Début d’exécution (dans le script main.py, juste après le test RunApp).

Heartbeat 

Allumée fixe avec une extinction de 200 ms toutes les 10 secondes.

En cours de fonctionnement.

Erreur

Successions de clignotements rapides entrecoupés de séquences de clignotements lents.

Le nombre de clignotements lents (de 1 à 6) indique un code d’erreur.

En cas d’erreur, l’ESP8266 est redémarré automatiquement (machine.reset()) après une heure.

Voir détail des codes d’erreurs ci-dessous.

Erreur 1

MQTTClient retourne un code d’erreur, code renvoyé par le broker MQTT.

Erreur 2

Erreur lors de la connexion MQTT. Vérifier les constantes MQTT_SERVER, MQTT_USE, MQTT_PSWD.

Le contenu du message d’exception est renvoyé sur la console REPL.

Erreur 3

Erreur durant le chargement des bibliothèques senseurs.

Vérifier la présence des différentes bibliothèques nécessaires et le fait qu’elles se chargent en mémoire sans erreur (par ex. en chargeant la bibliothèque dans une session REPL).

Le contenu du message d’exception est renvoyé sur la console REPL.

Erreur 4

Erreur durant la création des objets destinés à la lecture des différents senseurs.

Le contenu du message d’exception est renvoyé sur la console REPL.

Erreur 5

Erreur durant la publication de l’adresse MAC sur le topic connect/<clientID> lors du démarrage de l’objet.

Le contenu du message d’exception est renvoyé sur la console REPL.

Erreur 6

Erreur durant le traitement des tâches (capture de données sur les senseurs et publication MQTT).

Le contenu du message d’exception est renvoyé sur la console REPL.

3. Les topics MQTT
 
3. Les topics MQTT

Les topics MQTT et publications des informations sont détaillés dans le chapitre de la mise en place du broker MQTT, cf. Le broker MQTT - Topics du projet.  

4. Télécharger et préparer le code des objets IoT

Le code source des différents objets IoT est disponible sur le dépôt GitHub du projet : https://github.com/mchobby/la-maison-pythonic

Une copie des sources est également disponible dans les ressources de cet ouvrage.

Les sources peuvent être facilement téléchargées sur le Raspberry Pi à l’aide de l’utilitaire git.

cd ~ 

git clone https://github.com/mchobby/la-maison-pythonic.git

Un nouveau répertoire la-maison-pythonic est disponible dans le répertoire utilisateur (/home/pi). Ce dernier contient les sources des différents objets dans le sous-répertoire esp8266. Le répertoire esp8266 contient lui-même un sous-répertoire par objet.

Par exemple, le code destiné à la cabane est disponible dans le répertoire /home/pi/la-maison-pythonic/esp8266/cabane/.

images/04RI02.pngimages/04RI02.png
 

Code source de l’objet Cabane tel que disponible sur GitHub

Le script principal est bien entendu main.py, mais d’autres versions sont également disponibles comme test.py permettant de tester les senseurs et main_simple.py proposant une version intermédiaire, mais simplifiée, du code.

Bootstrap.sh

Le fichier bootstrap.sh permet de télécharger les dépendances (les bibliothèques Python) de l’objet cabane. Les bibliothèques des senseurs sont publiées sur un autre dépôt GitHub, le fichier bootstrap.sh fait le nécessaire pour les rapatrier dans le répertoire courant. Ces fichiers devront aussi être copiés sur l’ESP8266.

images/04RI03.pngimages/04RI03.png
 

Contenu du fichier bootstrap de l’objet cabane

Pour télécharger les dépendances :

1.

Ouvrir un terminal.

2.

Se placer dans le répertoire cabane.

3.

Exécuter le script boostrap.sh.

Si le dépôt GitHub a été cloné dans le répertoire utilisateur alors les dépendances (bibliothèques) peuvent être téléchargées comme suit :

cd ~/la-maison-pythonic/esp8266/cabane/ 

./bootstrap.sh

images/04RI04.pngimages/04RI04.png
 

Téléchargement des bibliothèques

Fonctionnement général d’un objet IoT

Tous les scripts des différents objets développés dans ce chapitre suivent une même structure de code.

Le code ci-dessous reprend la structure générale d’un objet.

01: # coding: utf8  

02: """ La Maison Pythonic - Object Cabane v0.2  

03:  

04:     Envoi des données toutes les heures + 30 minutes  

05:     vers serveur MQTT  

06:  """  

07:  

08: from machine import Pin, I2C, reset  

09: from time import sleep, time  

10: from ubinascii import hexlify  

11: from network import WLAN  

12:  

13: CLIENT_ID = ’cabane’  

14: MQTT_SERVER = "192.168.1.210"  

15:  

16: # Mettre à None si pas utile  

17: MQTT_USER = ’pusr103’  

18: MQTT_PSWD = ’21052017’  

19:  

20: # redémarrage auto après erreur  

21: ERROR_REBOOT_TIME = 3600 # 1 h = 3600 sec  

22:  

23: # --- Démarrage conditionnel ---  

24: runapp = Pin( 12,  Pin.IN, Pin.PULL_UP )  

25: led = Pin( 0, Pin.OUT )  

26: led.value( 1 ) # éteindre  

27:  

28: def led_error( step ):  

29:     global led  

30:     t = time()  

31:     while ( time()-t ) < ERROR_REBOOT_TIME:  

32:         for i in range( 20 ):  

33:             led.value(not(led.value()))  

34:             sleep(0.100)  

35:         led.value( 1 ) # éteindre  

36:         sleep( 1 )  

37:         # clignote nbr fois  

38:         for i in range( step ):  

39:             led.value( 0 )  

40:             sleep( 0.5 )  

41:             led.value( 1 )  

42:             sleep( 0.5 )  

43:         sleep( 1 )  

44:     # Re-start the ESP  

45:     reset()  

46:  

47: if runapp.value() != 1:  

48:     from sys import exit  

49:     exit(0)  

50:  

51: led.value( 0 ) # allumer  

52:  

53: # --- Programme Principal ---  

54: from umqtt.simple import MQTTClient  

55: try:  

56:     q = MQTTClient( client_id = CLIENT_ID,  

57:         server = MQTT_SERVER,  

58:         user = MQTT_USER,  

59:         password = MQTT_PSWD )  

60:     if q.connect() != 0:  

61:         led_error( step=1 )  

62: except Exception as e:  

63:     print( e )  

64:     # vérifier MQTT_SERVER, MQTT_USER, MQTT_PSWD  

65:     led_error( step=2 )  

66:  

67: try:  

68:     # Importation des bibliothèques  

69:     ...  

70: except Exception as e:  

71:     print( e )  

72:     led_error( step=3 )  

73:  

74: # déclare les bus  

75: i2c = I2C( sda=Pin(4), scl=Pin(5) )  

76:  

77: # créer les senseurs  

78: try:  

79:     # Création des senseurs  

80:     ...  

81: except Exception as e:  

82:     print( e )  

83:     led_error( step=4 )  

84:  

85: try:  

86:     # annonce connexion objet  

87:     sMac = hexlify( WLAN().config( ’mac’ ) ).decode()  

88:     q.publish( "connect/%s" % CLIENT_ID , sMac )  

89: except Exception as e:  

90:     print( e )  

91:     led_error( step=5 )  

92:  

93: import uasyncio as asyncio  

94:  

95: def capture_1h():  

96:     """ Exécuté pour capturer des  

97:         données chaque heure """  

98:     global q  

99:     ...autres global  

100:  

101:     # Lecture senseur  

102:     ...  

103:  

104:     # Publication  

105:     q.publish( "maison/temp", t )  

106:     ...  

107:  

108: def heartbeat():  

109:     """ Led éteinte 200ms toutes les 10 sec """  

110:     sleep( 0.2 )  

111:  

112: async def run_every( fn, min= 1, sec=None):  

113:     """ Execute a function fn every min  

114:         minutes or sec secondes"""  

115:     global led  

116:     wait_sec = sec if sec else min*60  

117:     while True:  

118:         # éteindre LED pendant envoi/traitement  

119:         led.value( 1 )  

120:         fn()  

121:         led.value( 0 ) # allumer  

122:         await asyncio.sleep( wait_sec )  

123:  

124: async def run_app_exit():  

125:     """ fin d’exécution lorsque  

126:         la fonction quitte """  

127:     global runapp  

128:     while runapp.value()==1:  

129:         await asyncio.sleep( 10 )  

130:     return  

131:  

132: loop = asyncio.get_event_loop()  

133: loop.create_task( run_every(capture_1h, min=60) )  

134: ...  

135: loop.create_task( run_every(heartbeat, sec=10) )  

136: try:  

137:     loop.run_until_complete( run_app_exit() )  

138: except Exception as e :  

139:     print( e )  

140:     led_error( step=6 )  

141:  

142: loop.close()  

143: led.value( 1 ) # eteindre  

144: print( "Fin!")

1. Principales sections

La section principale du script débute en ligne 53. Les lignes en deçà définissent des constantes et fonctions utilitaires.

À partir de la ligne 53, le script est scindé en sections protégées par des blocs try...except.

En cas d’erreur, chacun de ces blocs try...except :

Led_error() fournit une indication visuelle de l’erreur et prend en charge le redémarrage automatique de l’objet.

La liste ci-dessous énumère les différentes sections de code. En respectant scrupuleusement cette découpe, il est possible de savoir quelle partie du programme provoque l’erreur.  

2. Paramètres d’un objet IoT

Les paramètres du script sont regroupés entre les lignes 13 et 21 avec la déclaration des constantes suivantes :

CLIENT_ID : identification du client se connectant sur le broker MQTT.

MQTT_SERVER : identification du broker MQTT. Soit l’adresse IP, soit un nom de domaine pour un serveur en ligne. La résolution mDns n’étant pas prise en charge par MicroPython, il n’est pas recommandé d’utiliser une valeur telle que ’pythonic.local’. Voir la note au sujet de la résolution DNS dans le chapitre ESP8266 sous MicroPython - MQTT sous ESP8266.

MQTT_USER : compte utilisateur sur le broker MQTT. Utiliser la valeur None pour une connexion anonyme.

MQTT_PSWD : le mot de passe correspondant au compte utilisateur. None pour une connexion anonyme.

ERROR_REBOOT_TIME : temps au terme duquel l’objet est automatiquement redémarré lorsqu’une erreur s’est produite. 3 600 secondes par défaut (1h).

3. RunApp et la LED d’activité

La LED d’activité est branchée sur la broche 0 et fonctionne en logique inverse (cf. ESP8266 sous MicroPython - Prise de contrôle).

La variable led déclarée en ligne 25 permet de commander cette LED. Elle est immédiatement éteinte en ligne 26 avec l’instruction led.value( 1 ).

L’interrupteur RunApp est branché sur la broche #12, il permet d’arrêter le fonctionnement de l’objet (cf. ESP8266 sous MicroPython - Programmer).

La variable runapp déclarée en ligne 24 permet de lire l’état de l’interrupteur.

Le test if runapp.value()!= 1 en ligne 47 permet de vérifier l’état de l’interrupteur. Si ce dernier est fermé, l’instruction exit(0) permet d’interrompre le fonctionnement du script.

4. La fonction led_error()

La fonction led_error( step ) est appelée pour signaler une erreur.

L’erreur est signalée par une dizaine de clignotements rapides (intervalle de 100 ms) suivis d’un certain nombre de clignotements lents (intervalle de 1/2 seconde). Le nombre de clignotements lents (paramètre step) indique le code d’erreur.

28: def led_error( step ):  

29:     global led  

30:     t = time()  

31:     while ( time()-t ) < ERROR_REBOOT_TIME:  

32:         for i in range( 20 ):  

33:             led.value(not(led.value()))  

34:             sleep(0.100)  

35:         led.value( 1 ) # éteindre  

36:         sleep( 1 )  

37:         # clignote nbr fois  

38:         for i in range( step ):  

39:             led.value( 0 )  

40:             sleep( 0.5 )  

41:             led.value( 1 )  

42:             sleep( 0.5 )  

43:         sleep( 1 )  

44:     # redémarrer l’ESP  

45:     reset()

La fonction led_error( step ) se comporte comme un piège répétant indéfiniment la séquence d’erreur. Au terme d’un délai d’attente défini dans ERROR_REBOOT_TIME, le microcontrôleur est redémarré. Ce redémarrage permet de faire une nouvelle tentative de connexion sur le broker MQTT, commode si l’erreur est produite par une interruption des communications.

Ligne 29 : récupération de la variable globale led permettant de commander la LED.

Ligne 30 : capture du temps lors de l’activation de la fonction.

Ligne 31 : répéter la séquence d’erreur pendant ERROR_REBOOT_TIME secondes (soit 3 600 secondes, une heure).

Lignes 32-34 : faire clignoter rapidement la LED 10 fois (soit 20 changements d’état).

Lignes 35-36 : éteindre la LED et attendre 1 seconde avant d’envoyer le code d’erreur.

Lignes 38 -42 : afficher le code d’erreur en faisant clignoter step fois la LED.

Ligne 43 : attendre une seconde avant de redémarrer la séquence d’erreur.

Ligne 45 : l’instruction reset() exécutée au terme des ERROR_REBOOT_TIME secondes redémarre l’ESP8266.

5. Les tâches et fonctions asynchrones des objets IoT

Le support d’Asyncio sur ESP8266 et de la fonction run_every() a été traité dans le chapitre ESP8266 sous MicroPython à la section Asyncio sur ESP8266.

Pour rappel, la LED est éteinte par la fonction run_every() pendant que celle-ci exécute une tâche comme, par exemple, la fonction capture_1h().

Tâche de capture

La tâche capture_1h() est appelée toutes les heures. Cet appel est configuré à la ligne 133 par l’instruction loop.create_task( run_every(capture_1h, min=60) ).

Voici le code que l’on retrouve typiquement dans une tâche.

95: def capture_1h():  

96:     """ Exécuté pour capturer des  

97:         données chaque heure """  

98:     global q  

99:     ...autres global  

100:  

101:     # Lecture senseur  

102:     ...  

103:  

104:     # Publication  

105:     q.publish( "maison/temp", t )  

106:     ...

Ligne 98 : récupération de la variable globale q. Celle-ci offre un accès à l’instance du MQTTClient connecté sur le broker MQTT (voir ligne 56). Cette référence permettra de publier des messages sur le broker.

Ligne 99 : importation des autres variables globales correspondant aux senseurs (créés en lignes 79-80).

Ligne 102 : endroit où les senseurs sont interrogés et les données préparées pour une future publication. Par exemple : le relevé de la température et la constitution d’un message t de type str contenant la valeur de la température sous forme d’une chaîne de caractères.

Ligne 105 : exemple de publication de la température sur le topic maison/temp du broker MQTT.

La tâche heartbeat()

La tache heartbeat() éteint la LED pendant 200 ms toutes les 10 secondes. Cette fonction signale simplement que la boucle de traitement asyncio est toujours en cours d’exécution et donc que l’objet est toujours en cours d’exécution.

La tâche heartbeat() est configurée avec l’instruction loop.create_task( run_ every(heartbeat, sec=10) ) en ligne 135.

108: def heartbeat():  

109:     """ Led éteinte 200ms toutes les 10 sec """  

110:     sleep( 0.2 )

Pour rappel, run_every() éteint la LED pendant l’exécution d’une tâche. Par conséquent, lorsque heartbeat() est appelé, la LED est déjà éteinte.

La fonction heartbeat() doit seulement attendre 200 ms au terme desquelles la LED est rallumée par run_every().

La fonction asynchrone run_app_exit()

La fonction asynchrone run_app_exit() est utilisée pour quitter la boucle d’exécution Asyncio.

124: async def run_app_exit():  

125:     """ fin d’exécution lorsque quitte  

126:         la fonction """  

127:     global runapp  

128:     while runapp.value()==1:  

129:         await asyncio.sleep( 10 )  

130:     return  

131:  

132: loop = asyncio.get_event_loop()  

133: loop.create_task( run_every(capture_1h, min=60) )  

134: ...  

135: loop.create_task( run_every(heartbeat, sec=10) )  

136: try:  

137:     loop.run_until_complete( run_app_exit() )  

138: except Exception as e :  

139:     print( e )  

140:     led_error( step=6 )  

141:  

142: loop.close()  

143: led.value( 1 ) # éteindre  

144: print( "Fin!")

L’instruction loop.run_until_complete( run_app_exit() ) exécute la boucle de traitement et les différentes tâches asynchrones (lignes 133 à 135) en précisant une condition de sortie via run_app_exit().

La boucle de traitement asynchrone est interrompue lorsque la fonction asynchrone run_app_exit() termine son exécution.

La fonction asynchrone run_app_exit() fonctionne comme suit :

À la lecture des informations ci-dessus, il est facile de comprendre que la modification de la position de l’interrupteur RunApp ne prend effet que dans un délai de 0 à 10 secondes. Délai tout à fait correct pour une interruption de fonctionnement occasionnelle.

À noter que :

Objet 1 : Météo cabane de jardin

Le schéma suivant présente le montage réalisé pour la cabane de jardin. Celui-ci reprend trois senseurs (AM2315, BMP280 et TSL2561) sur le bus I2C du feather ESP8266. Le raccordement et le test individuel de ces senseurs est abordé dans le chapitre ESP8266 sous MicroPython.

L’interrupteur RunApp permet d’interrompre le fonctionnement de l’objet. Le fonctionnement de RunApp est également abordé en détail dans le chapitre ESP8266 sous MicroPython.

1. Schéma de raccordement

images/04RI90.pngimages/04RI90.png
 

Objet IoT de la cabane de jardin

2. Téléverser les scripts

Le script est disponible dans le répertoire esp8266/cabane/main.py du dépôt GitHub de l’ouvrage.

Le fichier bootstrap.sh permet de télécharger les bibliothèques utilisées par le script main.py dans le répertoire courant (à savoir am2315.py, bme280.py, tsl2561.py). Bootstrap.sh est un script shell qui peut être exécuté dans une ligne de commande du Raspberry Pi.

images/04RI94.pngimages/04RI94.png
 

Les fichiers disponibles sur le dépôt GitHub du projet

Après avoir modifié le fichier main.py pour y fixer les paramètres correspondant à la configuration actuelle, téléchargez le fichier main.py et les bibliothèques de dépendances sur l’ESP8266.

Ci-dessous les paramètres à adapter dans le fichier main.py.  

MQTT_SERVER = "192.168.1.210" 

# Mettre à None si pas utile 

MQTT_USER = ’pusr103’ 

MQTT_PSWD = ’21052017’

Les fichiers suivants pourront alors être téléversés sur la plateforme à l’aide d’un outil tel que RShell ou Ampy :

3. Fonctionnement du script

Conformément aux spécifications décrites dans le chapitre Le broker MQTT à la section Topics du projet :

01: # coding: utf8  

02: """ La Maison Pythonic - Object Cabane v0.2  

03:  

04:     Envoi des données 

05:     vers serveur MQTT  

06:  """  

07:  

08: from machine import Pin, I2C, reset  

09: from time import sleep, time  

10: from ubinascii import hexlify  

11: from network import WLAN  

12:  

13: CLIENT_ID = ’cabane’  

14:  

15: # Utiliser résolution DNS (serveur en ligne)  

16: # MQTT_SERVER = ’test.mosquitto.org’  

17: #  

18: # Utiliser IP si le Pi en adresse fixe  

19: # (plus fiable sur réseau local/domestique)  

20: # MQTT_SERVER = ’192.168.1.220’  

21: #  

22: # Utiliser le hostname si Pi en DHCP et que la propagation  

23: # du hostname atteint le modem/router (voir aussi gestion  

24: # mDns sur router).  

25: # (pas forcément fiable sur réseau domestique)  

26: # MQTT_SERVER = ’pythonic’  

27: #  

28: # Attention: MicroPython sous ESP8266 ne gère pas mDns!  

29:  

30: MQTT_SERVER = "192.168.1.210"  

31:  

32: # Mettre à None si pas utile  

33: MQTT_USER = ’pusr103’  

34: MQTT_PSWD = ’21052017’  

35:  

36: # redémarrage auto après erreur  

37: ERROR_REBOOT_TIME = 3600 # 1 h = 3600 sec  

38:  

39: # --- Démarrage conditionnel ---  

40: runapp = Pin( 12,  Pin.IN, Pin.PULL_UP )  

41: led = Pin( 0, Pin.OUT )  

42: led.value( 1 ) # éteindre  

43:  

44: def led_error( step ):  

45:     global led  

46:     t = time()  

47:     while ( time()-t ) < ERROR_REBOOT_TIME:  

48:         for i in range( 20 ):  

49:             led.value(not(led.value()))  

50:             sleep(0.100)  

51:         led.value( 1 ) # éteindre  

52:         sleep( 1 )  

53:         # clignote nbr fois  

54:         for i in range( step ):  

55:             led.value( 0 )  

56:             sleep( 0.5 )  

57:             led.value( 1 )  

58:             sleep( 0.5 )  

59:         sleep( 1 )  

60:     # Re-start the ESP  

61:     reset()  

62:  

63: if runapp.value() != 1:  

64:     from sys import exit  

65:     exit(0)  

66:  

67: led.value( 0 ) # allumer  

68:  

69: # --- Programme Pincipal ---  

70: from umqtt.simple import MQTTClient  

71: try:  

72:     q = MQTTClient( client_id = CLIENT_ID, server =  

73:          MQTT_SERVER, user = MQTT_USER, password =  

74:          MQTT_PSWD )  

75:     if q.connect() != 0:  

76:         led_error( step=1 )  

77: except Exception as e:  

78:     print( e )  

79:     # check MQTT_SERVER, MQTT_USER, MQTT_PSWD  

80:     led_error( step=2 )  

81:  

82: try:  

83:     from tsl2561 import TSL2561  

84:     from bme280 import BME280, BMP280_I2CADDR  

85:     from am2315 import AM2315  

86: except Exception as e:  

87:     print( e )  

88:     led_error( step=3 )  

89:  

90: # declare le bus i2c  

91: i2c = I2C( sda=Pin(4), scl=Pin(5) )  

92:  

93: # créer les senseurs  

94: try:  

95:     tsl = TSL2561( i2c=i2c )  

96:     bmp = BME280( i2c=i2c, address=BMP280_I2CADDR )  

97:     am = AM2315( i2c=i2c )  

98: except Exception as e:  

99:     print( e )  

100:     led_error( step=4 )  

101:  

102: try:  

103:     # annonce connexion objet  

104:     sMac = hexlify( WLAN().config( ’mac’ ) ).decode()  

105:     q.publish( "connect/%s" % CLIENT_ID , sMac )  

106: except Exception as e:  

107:     print( e )  

108:     led_error( step=5 )  

109:  

110: import uasyncio as asyncio  

111:  

112: def capture_1h():  

113:     """ Exécuté pour capturer des données chaque heure """  

114:     global q  

115:     global tsl  

116:     global am  

117:     # tsl2561 - senseur lux  

118:     lux = "{0:.2f}".format( tsl.read() )  

119:     q.publish( "maison/exterieur/cabane/lux", lux )  

120:     # am2315 - humidité/température  

121:     am.measure() # réactive le senseur  

122:     sleep( 1 )  

123:     am.measure()  

124:     t = "{0:.2f}".format( am.temperature() )  

125:     h = "{0:.2f}".format( am.humidity() )  

126:     q.publish( "maison/exterieur/jardin/temp", t )  

127:     q.publish( "maison/exterieur/jardin/hrel", h )  

128:  

129: def capture_20min():  

130:     """ Exécuté pour capturer des données chaque 20 minutes """  

131:     global q  

132:     global bmp  

133:     # bmp280 - senseur pression/température  

134:     # capturer les valeurs sous format texte  

135:     (t,p,h) = bmp.raw_values  

136:     # transformer en chaines de caractère  

137:     t = "{0:.2f}".format(t)   

138:     p = "{0:.2f}".format(p)  

139:     q.publish( "maison/exterieur/cabane/pathm", p )  

140:     q.publish( "maison/exterieur/cabane/temp", t )  

141:  

142: def heartbeat():  

143:     """ Led éteinte 200ms toutes les 10 sec """  

144:     sleep( 0.2 )  

145:  

146: async def run_every( fn, min= 1, sec=None):  

147:     """ Exécute une fonction fn toutes les minutes ou 

148:          secondes"""  

149:     global led  

150:     wait_sec = sec if sec else min*60  

151:     while True:  

152:         # éteindre pendant envoi/traitement  

153:         led.value( 1 )  

154:         fn()  

155:         led.value( 0 ) # allumer  

156:         await asyncio.sleep( wait_sec )  

157:  

158: async def run_app_exit():  

159:     """ fin d’exécution lorsque la fonction quitte """  

160:     global runapp  

161:     while runapp.value()==1:  

162:         await asyncio.sleep( 10 )  

163:     return  

164:  

165: loop = asyncio.get_event_loop()  

166: loop.create_task( run_every(capture_1h, min=60) )  

167: loop.create_task( run_every(capture_20min, min=20) )  

168: loop.create_task( run_every(heartbeat, sec=10) )  

169: try:  

170:     loop.run_until_complete( run_app_exit() )  

171: except Exception as e :  

172:     print( e )  

173:     led_error( step=6 )  

174:  

175: loop.close()  

176: led.value( 1 ) # éteindre  

177: print( "Fin!")  

Le fonctionnement général de l’objet étant décrit ci-avant, cette section se concentre sur les éléments clés du script.

Les senseurs sont créés entre les lignes 95 et 97.

95:     tsl = TSL2561( i2c=i2c )  

96:     bmp = BME280( i2c=i2c, address=BMP280_I2CADDR )  

97:     am = AM2315( i2c=i2c )

Le script prévoit deux tâches capture_1h() et capture_20min() pour répondre aux spécifications.

166: loop.create_task( run_every(capture_1h, min=60) )  

167: loop.create_task( run_every(capture_20min, min=20) )

La fonction capture_1h

La fonction capture_1h() publie les données des senseurs TSL2561 et AM2315 toutes les heures.

112: def capture_1h():  

113:     """ Exécuté pour capturer des données chaque heure """  

114:     global q  

115:     global tsl  

116:     global am  

117:     # tsl2561 - senseur lux  

118:     lux = "{0:.2f}".format( tsl.read() )  

119:     q.publish( "maison/exterieur/cabane/lux", lux )  

120:     # am2315 - humidité/température  

121:     am.measure() # réactive le senseur  

122:     sleep( 1 )  

123:     am.measure()  

124:     t = "{0:.2f}".format( am.temperature() )  

125:     h = "{0:.2f}".format( am.humidity() )  

126:     q.publish( "maison/exterieur/jardin/temp", t )  

127:     q.publish( "maison/exterieur/jardin/hrel", h )

Lignes 114 à 116 : récupération des variables globales correspondant respectivement au client MQTT, senseur TSL2561 et senseur AM2315.

Ligne 118 : capture de la valeur en Lux avec tsl.read() qui retourne une valeur numérique. La valeur est convertie en chaîne de caractères avec deux valeurs décimales en utilisant la méthode format(). En effet, "{0:.2f}".format( 1.9187 ) produit le résultat ’1.92’.

Ligne 119 : publication de la luminosité sur le topic maison/exterieur/cabane/lux.

Lignes 121 à 123 : le premier appel de am.measure() réactive le senseur et renvoie immédiatement la dernière valeur échantillonnée une heure plus tôt et gardée en mémoire. La deuxième lecture effectue un nouvel échantillonnage.

Lignes 124 à 125 : transformation des valeurs de température et d’humidité relative en chaînes de caractères.

Lignes 126 à 127 : publication de la température et de l’humidité du jardin sur le broker MQTT.

La fonction capture_20min

La fonction capture_20m() publie les données du senseur BMP280 toutes les 20 minutes. Cette fréquence de capture est suffisante pour permettre à un système distant d’envisager une analyse de prévision météo sommaire à partir de l’historique des données collectées.

129: def capture_20min():  

130:     """ Exécuté pour capturer des données chaque 20 minutes """  

131:     global q  

132:     global bmp  

133:     # bmp280 - senseur pression/température  

134:     # capturer les valeurs sous format texte  

135:     (t,p,h) = bmp.raw_values  

136:     # transformer en chaines de caractères  

137:     t = "{0:.2f}".format(t)   

138:     p = "{0:.2f}".format(p)  

139:     q.publish( "maison/exterieur/cabane/pathm", p )  

140:     q.publish( "maison/exterieur/cabane/temp", t )

Lignes 131 et 132 : récupération des variables globales du client MQTT et du senseur BMP280.

Ligne 135 : capture des données du senseur BMP280 sous la forme d’un tuple de trois valeurs (température, pression, humidité). La bibliothèque étant également prévue pour le senseur BME280, la propriété raw_values retourne une 3e valeur correspondant à l’humidité relative. Dans le cas d’un BMP280, l’humidité relative est systématiquement à 0. L’instruction (t,p,h) = bmp.raw_values permet d’assigner, en une seule opération, les trois valeurs respectivement aux variables t, p et h.

Ligne 137 : conversion de la valeur numérique de la température en chaîne de caractères avec deux valeurs décimales. En effet, "{0:.2f}".format( 25.4 ) produit le résultat ’25.40’.

Ligne 138 : conversion de la pression atmosphérique en chaîne de caractères.

Ligne 139 : publication de la pression atmosphérique sur le topic maison/exterieur/cabane/pathm.

Ligne 140 : publication de la température.

4. Tester l’objet

Tester l’objet est relativement simple. En utilisant l’utilitaire mosquitto_sub, il est possible de capturer tous les messages publiés sur le broker à l’aide de la commande suivante :

mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017

Les détails de l’utilitaire sont abordés dans le chapitre Le broker MQTT à la section Test avec Mosquitto.org.

Les messages suivants apparaissent après la mise-sous-tension de l’objet.

pi@pythonic:~ $ mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017 

 

connect/cabane 5ccf7fc6d4e3  

maison/exterieur/cabane/lux 3.94  

maison/exterieur/jardin/temp 20.60  

maison/exterieur/jardin/hrel 48.10  

maison/exterieur/cabane/pathm 989.10  

maison/exterieur/cabane/temp 19.71

Si les messages n’apparaissent pas, rendez-vous dans la section Dépannage d’un objet IoT.

Objet 2 : Surveillance salon

Le schéma suivant présente le montage réalisé pour la surveillance du salon. Celui-ci reprend un senseur de température analogique (TMP36) associé à un breakout de conversion analogique/numérique ADS1115. Le senseur PIR permet de réaliser une détection de présence avec une annonce répétitive en cas de présence.

L’interrupteur RunApp permet d’interrompre le fonctionnement de l’objet. Le fonctionnement de RunApp est abordé en détail dans le chapitre ESP8266 sous MicroPython.

images/04RI91.pngimages/04RI91.png
 

Objet IoT du salon

1. Téléverser les scripts

Le script est disponible dans le répertoire esp8266/salon/main.py du dépôt GitHub de l’ouvrage.

Le fichier bootstrap.sh permet de télécharger les bibliothèques nécessaires au bon fonctionnement de main.py (à savoir ads1x15.py). Le script shell bootstrap.sh peut être exécuté depuis une ligne de commande sur le Raspberry Pi.

Le fichier main.py doit être modifié pour fixer les paramètres correspondant à la configuration actuelle.

Ci-dessous les paramètres à adapter dans le fichier main.py.

MQTT_SERVER = "192.168.1.210" 

# Mettre a None si pas utile 

MQTT_USER = ’pusr103’ 

MQTT_PSWD = ’21052017’

Les fichiers suivants pourront alors être téléversés sur la plateforme à l’aide d’un outil tel que RShell ou Ampy :

2. Fonctionnement du script

Conformément aux spécifications décrites dans le chapitre Le broker MQTT à la section Topics du projet :

01: # coding: utf8  

02: """ La Maison Pythonic - Object Salon v0.1  

03:  

04:     Envoi des données température et senseur PIR vers  

05:          serveur MQTT  

06:  """  

07:  

08: from machine import Pin, I2C, reset  

09: import time  

10: from ubinascii import hexlify  

11: from network import WLAN  

12:  

13: CLIENT_ID = ’salon’  

14:  

15: # Utiliser la résolution DNS (serveur en ligne)  

16: # MQTT_SERVER = ’test.mosquitto.org’  

17: #  

18: # Utiliser IP si le Pi en adresse fixe  

19: # (plus fiable sur réseau local/domestique)  

20: # MQTT_SERVER = ’192.168.1.220’  

21: #  

22: # Utiliser le hostname si Pi en DHCP et que la propagation  

23: #    du  

24: # hostname atteint le modem/routeur (voir aussi gestion mDns  

25: #    sur routeur).  

26: # (pas forcément fiable sur réseau domestique)  

27: # MQTT_SERVER = ’pythonic’  

28: #  

29: # Attention: MicroPython sous ESP8266 ne gère pas mDns!  

30:  

31: MQTT_SERVER = "192.168.1.210"  

32:  

33: # Mettre à None si pas utile  

34: MQTT_USER = ’pusr103’  

35: MQTT_PSWD = ’21052017’  

36:  

37: # redémarrage auto après erreur  

38: ERROR_REBOOT_TIME = 3600 # 1 h = 3600 sec  

39:  

40: # PIR  

41: PIR_PIN = 13 # Signal du senseur PIR.  

42: PIR_RETRIGGER_TIME = 15 * 60 # 15 min  

43: # temps (sec) dernière activation PIR  

44: last_pir_time = 0  

45: last_pir_msg  = "NONE"  

46: # temps (sec) dernier envoi MSG  

47: last_pir_msg_time = 0  

48: # Programme principal doit-il envoyer  

49: # une notification "MOUV" rapidement?  

50: fire_pir_alert = False  

51:  

52: # --- Démarrage conditionnel ---  

53: runapp = Pin( 12,  Pin.IN, Pin.PULL_UP )  

54: led = Pin( 0, Pin.OUT )  

55: led.value( 1 ) # éteindre  

56:  

57: def led_error( step ):  

58:     global led  

59:     t = time.time()  

60:     while ( time.time()-t ) < ERROR_REBOOT_TIME:  

61:         for i in range( 20 ):  

62:             led.value(not(led.value()))  

63:             time.sleep(0.100)  

64:         led.value( 1 ) # éteindre  

65:         time.sleep( 1 )  

66:         # clignote nbr fois  

67:         for i in range( step ):  

68:             led.value( 0 )  

69:             time.sleep( 0.5 )  

70:             led.value( 1 )  

71:             time.sleep( 0.5 )  

72:         time.sleep( 1 )  

73:     # Redémarre l’ESP  

74:     reset()  

75:  

76: if runapp.value() != 1:  

77:     from sys import exit  

78:     exit(0)  

79:  

80: led.value( 0 ) # allumer  

81:  

82: # --- Programme Pincipal ---  

83: from umqtt.simple import MQTTClient  

84: try:  

85:     q = MQTTClient( client_id = CLIENT_ID, server =  

86:          MQTT_SERVER, user = MQTT_USER, password =  

87:          MQTT_PSWD )  

88:     if q.connect() != 0:  

89:         led_error( step=1 )  

90: except Exception as e:  

91:     print( e )  

92:     # Vérifier MQTT_SERVER, MQTT_USER, MQTT_PSWD  

93:     led_error( step=2 )  

94:  

95: # chargement des bibliothèques  

96: try:  

97:     from ads1x15 import *  

98:     from machine import Pin  

99: except Exception as e:  

100:     print( e )  

101:     led_error( step=3 )  

102:  

103: # déclare le bus i2c  

104: i2c = I2C( sda=Pin(4), scl=Pin(5) )  

105:  

106: # gestion du senseur PIR  

107: def pir_activated( p ):  

108:     # print( ’pir activated @ %s’ % time.time() )  

109:     global last_pir_time, last_pir_msg, fire_pir_alert  

110:     last_pir_time = time.time()  

111:     # Faut-il lancer un message MOUV rapidement?  

112:     # Initialiser le drapeau pour la boucle principale  

113:     fire_pir_alert = (last_pir_msg == "NONE")  

114:  

115: # créer les senseurs  

116: try:  

117:     adc = ADS1115( i2c=i2c, address=0x48, gain=0 )  

118:  

119:     pir_sensor = Pin( PIR_PIN, Pin.IN )  

120:     pir_sensor.irq( trigger=Pin.IRQ_RISING,  

121:          handler=pir_activated )  

122: except Exception as e:  

123:     print( e )  

124:     led_error( step=4 )  

125:  

126: try:  

127:     # annonce connexion objet  

128:     sMac = hexlify( WLAN().config( ’mac’ ) ).decode()  

129:     q.publish( "connect/%s" % CLIENT_ID , sMac )  

130: except Exception as e:  

131:     print( e )  

132:     led_error( step=5 )  

133:  

134: import uasyncio as asyncio  

135:  

136: def capture_1h():  

137:     """ Exécuté pour capturer des données chaque heure """  

138:     global q  

139:     global adc  

140:     # tmp36 - senseur température  

141:     valeur = adc.read( rate=0, channel1=0 )  

142:     mvolts = valeur * 0.1875  

143:     t = (mvolts - 500)/10  

144:     t = "{0:.2f}".format(t)  # transformer en chaine de  

145:         # caractères  

146:     q.publish( "maison/rez/salon/temp", t )  

147:  

148: def heartbeat():  

149:     """ Led éteinte 200ms toutes les 10 sec """  

150:     # PS: LED déjà éteinte par run_every!  

151:     time.sleep( 0.2 )  

152:  

153: def pir_alert():  

154:     """ Envoyer un MOUV en urgence sur topic salon/pir  

155:         si fire_pir_alert """  

156:     global fire_pir_alert, last_pir_msg, last_pir_msg_time  

157:     if fire_pir_alert:  

158:         fire_pir_alert=False # désactiver l’alerte!  

159:         last_pir_msg = "MOUV"  

160:         last_pir_msg_time = time.time()  

161:         q.publish( "/maison/rez/salon/pir", last_pir_msg )  

162:  

163: def pir_update():  

164:     """ Mise à jour régulière du topic salon/pir """  

165:     global last_pir_msg, last_pir_msg_time  

166:     if (time.time() - last_pir_msg_time) <  

167:          PIR_RETRIGGER_TIME:  

168:         # ce n’est pas le moment d envoyer un  

169:         # message de mise-à-jour  

170:         return  

171:  

172:     # PIR activé depuis les x dernière minutes  

173:     if (time.time() - last_pir_time) < PIR_RETRIGGER_TIME:  

174:         msg = "MOUV"  

175:     else:  

176:         msg = "NONE"  

177:      

178:     # ne pas renvoyer les NONE  

179:     if msg == "NONE" == last_pir_msg:  

180:         return  

181:      

182:     # Publier le msg  

183:     last_pir_msg = msg  

184:     last_pir_msg_time = time.time()  

185:     q.publish( "/maison/rez/salon/pir", last_pir_msg )  

186:  

187:  

188: async def run_every( fn, min= 1, sec=None):  

189:     global led  

190:     wait_sec = sec if sec else min*60  

191:     while True:  

192:         led.value( 1 ) # éteindre pendant envoi/traitement  

193:         try:  

194:             fn()  

195:         except Exception:  

196:             print( "run_every catch exception for %s" % fn)  

197:             raise # quitter boucle  

198:         led.value( 0 ) # allumer  

199:         await asyncio.sleep( wait_sec )  

200:  

201: async def run_app_exit():  

202:     """ fin d’exécution lorsque la fonction quitte """  

203:     global runapp  

204:     while runapp.value()==1:  

205:         await asyncio.sleep( 10 )  

206:     return  

207:  

208: loop = asyncio.get_event_loop()  

209: loop.create_task( run_every(capture_1h, min=60) )  

210: loop.create_task( run_every(pir_alert, sec=10) )  

211: loop.create_task( run_every(pir_update, min=5))  

212: loop.create_task( run_every(heartbeat, sec=10) )  

213: try:  

214:     loop.run_until_complete( run_app_exit() )  

215: except Exception as e :  

216:     print( e )  

217:     led_error( step=6 )  

218:  

219: # Désactive l’IRQ  

220: pir_sensor = Pin( PIR_PIN, Pin.IN )  

221:  

222: loop.close()  

223: led.value( 1 ) # éteindre  

224: print( "Fin!")

Le fonctionnement général de l’objet ayant déjà été décrit, cette section se concentre sur les éléments clés du script.

Les senseurs sont créés entre les lignes 117 et 121.

117:     adc = ADS1115( i2c=i2c, address=0x48, gain=0 )  

118:  

119:     pir_sensor = Pin( PIR_PIN, Pin.IN )  

120:     pir_sensor.irq( trigger=Pin.IRQ_RISING,  

121:          handler=pir_activated )

La lecture de la température sur le TMP36 passe par le convertisseur ADC, raison de la création de l’objet adc en ligne 117.

Le senseur PIR active un signal de sortie pendant plusieurs secondes lorsqu’il détecte un mouvement. Cette activation est capturée en configurant une interruption sur la broche d’entrée à la ligne 120. L’interruption est déclenchée sur le flan montant du signal avec Pin.IRQ_RISING et traitée par la fonction de rappel pir_activated(). En conséquence, la fonction pir_activated() est rappelée à chaque fois que le senseur PIR est activé.

Le script prévoit trois tâches pour répondre aux spécifications :

209: loop.create_task( run_every(capture_1h, min=60) )  

210: loop.create_task( run_every(pir_alert, sec=10) )  

211: loop.create_task( run_every(pir_update, min=5))

3. La fonction capture_1h()

La fonction capture_1h() publie la température du TMP36 toutes les heures.

136: def capture_1h():  

137:     """ Exécuté pour capturer des données chaque heure """  

138:     global q  

139:     global adc  

140:     # tmp36 - senseur température  

141:     valeur = adc.read( rate=0, channel1=0 )  

142:     mvolts = valeur * 0.1875  

143:     t = (mvolts - 500)/10  

144:     t = "{0:.2f}".format(t)  # transformer en chaine de  

145:         # caractères  

146:     q.publish( "maison/rez/salon/temp", t )

Ligne 138 et 139 : récupération des variables globales correspondant au ClientMQTT et au convertisseur ADS1115.

Ligne 141 : lecture de l’entrée analogique A0 du convertisseur.

Ligne 142 : conversion en millivolts. Le rapport de conversion 0,1875 dépend du gain de l’amplificateur (voir ligne 117). Le rapport à utiliser est détaillé dans le chapitre ESP8266 sous MicroPython - Programmer.

Ligne 143 : conversion de la tension (mV) en température. Formule issue de la fiche technique du TMP36.

Ligne 144 : conversion sous forme de chaîne de caractères avec 2 décimales.

Ligne 146 : publication du message sur le topic maison/rez/salon/temp.

4. Senseur PIR - variables et utilisation

La gestion du senseur PIR nécessite la mise en place de plusieurs variables définies entre les lignes 42 et 50. Celles-ci sont exploitées par les fonctions :

40: # PIR  

41: PIR_PIN = 13 # Signal du senseur PIR.  

42: PIR_RETRIGGER_TIME = 15 * 60 # 15 min  

43: # temps (sec) dernière activation PIR  

44: last_pir_time = 0  

45: last_pir_msg  = "NONE"  

46: # temps (sec) dernier envoi MSG  

47: last_pir_msg_time = 0  

48: # Programme principal doit-il envoyer  

49: # une notification "MOUV" rapidement?  

50: fire_pir_alert = False

Ligne 42 : PIR_RETRIGGER_TIME est le temps de republication du message « MOUV » lorsque le senseur PIR est régulièrement réactivé durant cette période. Constante exprimée en seconde.

Ligne 44 : variable last_pir_time retient l’heure (écoulement du temps en seconde) à laquelle le senseur PIR a été activé pour la dernière fois. Cette variable est assignée par la fonction de rappel pir_activated() du senseur PIR.

Ligne 45 : last_pir_msg est le dernier message envoyé sur le broker MQTT. Pour rappel, « MOUV » est renvoyé toutes les PIR_RETRIGGER_TIME secondes (15 minutes) alors que le message « NONE » ne doit pas être renvoyé.

Ligne 47 : variable last_pir_msg_time retient l’heure (écoulement du temps en seconde) à laquelle le dernier message « MOUV » ou « NONE » a été envoyé sur le broker. Message contenu dans la variable last_pir_msg.

Ligne 50 : le drapeau fire_pir_alert est utilisé pour signaler l’envoi d’un message « MOUV » en urgence, ce qui est typiquement le cas lorsque le senseur PIR est réactivé après une longue période d’inactivité. Ce drapeau est lu dans la fonction pir_alert() pour déclencher l’envoi du message tandis que la fonction de rappel pir_activated(), attachée au senseur PIR, active ce drapeau lorsque les conditions d’envoi sont rencontrées.

5. Senseur PIR - la fonction pir_activated

Cette fonction de rappel est appelée par l’interruption associée à la broche 13 (voir lignes 120 et 121). Cette fonction est appelée à chaque fois que le senseur PIR est activé.

107: def pir_activated( p ):  

108:     # print( ’pir activated @ %s’ % time.time() )  

109:     global last_pir_time, last_pir_msg, fire_pir_alert  

110:     last_pir_time = time.time()  

111:     # Faut-il lancer un message MOUV rapidement?  

112:     # Initialiser le drapeau pour la boucle principale  

113:     fire_pir_alert = (last_pir_msg == "NONE")

Ligne 109 : récupération des variables globales utiles.

Ligne 110 : mémoriser l’heure de dernière activation du senseur PIR.

Ligne 113 : initialiser le drapeau fire_pir_alert indiquant qu’il faut envoyer un message « MOUV » rapidement. Ce qui est le cas si le dernier message envoyé est « NONE » (plus d’activité) et qu’une nouvelle activité est détectée (ce qui est effectif puisque pir_activated est en cours d’exécution).

6. Senseur PIR - la fonction pir_alert

La fonction asynchrone pir_alert() est appelée par la boucle de traitement asyncio toutes les 10 secondes. Cette dernière n’a qu’une seule fonction : envoyer rapidement un message « MOUV » lors d’une nouvelle détection de mouvement. Dix secondes est un temps de latence raisonnable pour envoyer l’alerte.

153: def pir_alert():  

154:     """ Envoyer un MOUV en urgence sur topic salon/pir  

155:         si fire_pir_alert """  

156:     global fire_pir_alert, last_pir_msg, last_pir_msg_time  

157:     if fire_pir_alert:  

158:         fire_pir_alert=False # desactiver l’alerte!  

159:         last_pir_msg = "MOUV"  

160:         last_pir_msg_time = time.time()  

161:         q.publish( "/maison/rez/salon/pir", last_pir_msg )

Ligne 156 : récupération des variables globales.

Ligne 157 : test du drapeau fire_pir_alert (drapeau modifié par pir_activated). Si ce dernier est vrai, alors il faut envoyer un message « MOUV » (lignes de 158 à 161).

Ligne 158 : désactiver le drapeau pour éviter l’envoi à répétition (toutes les 10 secondes).

Ligne 159 : mémoriser « MOUV » comme dernier message envoyé sur le broker MQTT.

Ligne 160 : mémoriser l’heure d’envoi du message.

Ligne 161 : publication du message « MOUV » sur le broker.

7. Senseur PIR - la fonction pir_update

La fonction asynchrone pir_update() est appelée par la boucle de traitement asyncio toutes les 5 minutes. Sa tâche est de faire une mise à jour du topic /maison/rez/salon/pir à intervalle régulier. Donc de répéter les messages « MOUV » lorsque cela est applicable et un unique message « NONE » lorsqu’il n’y a plus de mouvement détecté.

163: def pir_update():  

164:     """ Mise à jour régulière du topic salon/pir """  

165:     global last_pir_msg, last_pir_msg_time  

166:     if (time.time() - last_pir_msg_time) <  

167:          PIR_RETRIGGER_TIME:  

168:         # ce n est pas le moment d envoyer un  

169:         # message de mise-a-jour  

170:         return  

171:  

172:     # PIR activé depuis les x dernières minutes  

173:     if (time.time() - last_pir_time) < PIR_RETRIGGER_TIME:  

174:         msg = "MOUV"  

175:     else:  

176:         msg = "NONE"  

177:      

178:     # ne pas renvoyer les NONE  

179:     if msg == "NONE" == last_pir_msg:  

180:         return  

181:      

182:     # Publier le msg  

183:     last_pir_msg = msg  

184:     last_pir_msg_time = time.time()  

185:     q.publish( "/maison/rez/salon/pir", last_pir_msg )

Ligne 165 : récupération des variables globales.

Ligne 166 : terminer le traitement de la fonction si le temps écoulé depuis l’envoi du dernier message est inférieur à 15 minutes (PIR_RETRIGGER_TIME secondes).

Lignes 173 à 176 : si le senseur PIR a été activé ces 15 dernières minutes, alors le message « MOUV » doit être envoyé, sinon le message « NONE » servira à signaler la fin d’activité.

Lignes 179 à 180 : terminer le traitement de la fonction si le nouveau message est « NONE » et celui-ci identique au précédent message (« NONE » ne doit être publié qu’une seule fois).

Lignes 183 et 184 : mémorisation du dernier message envoyé, ainsi que de l’heure d’envoi.

Ligne 185 : publication du message sur le topic /maison/rez/salon/pir.

8. Problèmes de concurrence

L’intrication des appels des différentes fonctions pir_activated(), pir_alert(), pir_update() laisse entrevoir des problèmes d’accès concurrents entre les interruptions (irq) et asyncio.

La plateforme MicroPython ne gère pas le traitement multitâche et les fonctions asynchrones travaillent en mode coopératif. De la sorte, l’exécution d’une fonction asynchrone ne vient jamais interrompre inopinément l’exécution d’une autre fonction asynchrone.

La seule portion de code pouvant faire l’objet de conditions concurrentes est pir_activated(). Cette dernière est appelée sur base d’une interruption et peut donc interrompre l’exécution d’une fonction testant/modifiant les mêmes variables que celle modifiée par la fonction d’interruption pir_activated().

La prise en compte des conditions de concurrence nécessite la désactivation et la réactivation des interruptions par les instructions machine.disable_irq() et machine.enable_irq() durant le traitement de la section critique. La désactivation des interruptions empêchera l’appel de pir_activated(). La mise en place de la section critique a cependant été écartée dans ce script pour des raisons de simplicité. Le risque concurrent et les conséquences étant tous deux négligeables dans le cas présent.

Pour plus d’informations :

9. Tester l’objet

Tester l’objet est relativement simple. En utilisant l’utilitaire mosquitto_sub, il est possible de capturer tous les messages publiés sur le broker à l’aide de la commande suivante :

mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017

Les détails de l’utilitaire sont abordés dans le chapitre Le broker MQTT à la section Test avec Mosquitto.org.

Les messages suivants apparaissent après la mise-sous-tension de l’objet.

pi@pythonic:~ $ mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017 

 

connect/salon 5ccf7f88bb0e  

maison/rez/salon/temp 22.09  

maison/rez/salon/pir MOUV

Si les messages n’apparaissent pas, rendez-vous dans la section Dépannage d’un objet IoT.

Objet 3 : Surveillance de la véranda

Le schéma suivant présente le montage réalisé pour la surveillance de la véranda. Celui-ci reprend un senseur de température analogique (TMP36) associé à un breakout de conversion analogique/numérique ADS1115. Le breakout est également utilisé avec une photo résistance pour évaluer les conditions d’éclairage du point lumineux et un potentiomètre fixant le seuil utilisé pour déterminé l’état NOIR/ECLAIRAGE de la photorésistance.

L’interrupteur RunApp permet d’interrompre le fonctionnement de l’objet. Le fonctionnement de RunApp est abordé en détail dans le chapitre ESP8266 sous MicroPython.

images/04RI92.pngimages/04RI92.png
 

Objet IoT de la véranda

1. Téléverser les scripts

Le script est disponible dans le répertoire esp8266/veranda/main.py du dépôt GitHub de l’ouvrage.

Le fichier bootstrap.sh permet de télécharger les bibliothèques nécessaires au bon fonctionnement de main.py (à savoir ads1x15.py). Le script shell bootstrap.sh peut être exécuté depuis une ligne de commande sur le Raspberry Pi.

Le fichier main.py doit être modifié pour fixer les paramètres correspondant à la configuration actuelle.

Ci-dessous les paramètres à adapter dans le fichier main.py.

MQTT_SERVER = "192.168.1.210" 

# Mettre a None si pas utile 

MQTT_USER = ’pusr103’ 

MQTT_PSWD = ’21052017’

Les fichiers suivants pourront alors être téléversés sur la plateforme à l’aide d’un outil tel que RShell ou Ampy :

2. Fonctionnement du script

Conformément aux spécifications décrites dans le chapitre Le broker MQTT à la section Topics du projet :

01: # coding: utf8  

02: """ La Maison Pythonic - Object Veranda v0.1  

03:  

04:     Envoi des données de température et du contact magnétique vers le 

05:          serveur MQTT  

06:  """  

07:  

08: from machine import Pin, I2C, reset  

09: import time  

10: from ubinascii import hexlify  

11: from network import WLAN  

12:  

13: CLIENT_ID = ’veranda’  

14:  

15: # Utiliser résolution DNS (serveur en ligne)  

16: # MQTT_SERVER = ’test.mosquitto.org’  

17: #  

18: # Attention: MicroPython sous ESP8266 ne gère pas mDns!  

19:  

20: MQTT_SERVER = "192.168.1.210"  

21:  

22: # Mettre à None si pas utile  

23: MQTT_USER = ’pusr103’  

24: MQTT_PSWD = ’21052017’  

25:  

26: # redémarrage auto après erreur  

27: ERROR_REBOOT_TIME = 3600 # 1 h = 3600 sec  

28:  

29: # Contact  

30: CONTACT_PIN = 13 # Signal du senseur PIR.  

31: last_contact_state = 0 # 0=fermé, 1=ouvert  

32:  

33: # Etat LDR  

34: #    Valeur d’hysteresis (pour éviter le  

35: #    basculement continuel)  

36: LDR_HYST  = 200   

37: last_ldr_state = "NOIR" # Noir ou ECLAIRAGE  

38:  

39: def ldr_to_state( adc_ldr, adc_pivot ):  

40:     """ Transforme la valeur adc lue en NOIR et ECLAIRAGE  

41:          """  

42:     global last_ldr_state  

43:     # print( "adc_ldr, adc_pivot = %s, %s" %  

44:     #        (adc_ldr, adc_pivot) )  

45:     if adc_ldr > (adc_pivot+LDR_HYST):  

46:         return "ECLAIRAGE"  

47:     elif adc_ldr < (adc_pivot-LDR_HYST):  

48:         return "NOIR"  

49:     else:  

50:         return last_ldr_state  

51:  

52: # --- Démarrage conditionnel ---  

53: runapp = Pin( 12,  Pin.IN, Pin.PULL_UP )  

54: led = Pin( 0, Pin.OUT )  

55: led.value( 1 ) # éteindre  

56:  

57: def led_error( step ):  

58:     global led  

59:     t = time.time()  

60:     while ( time.time()-t ) < ERROR_REBOOT_TIME:  

61:         for i in range( 20 ):  

62:             led.value(not(led.value()))  

63:             time.sleep(0.100)  

64:         led.value( 1 ) # éteindre  

65:         time.sleep( 1 )  

66:         # clignote nbr fois  

67:         for i in range( step ):  

68:             led.value( 0 )  

69:             time.sleep( 0.5 )  

70:             led.value( 1 )  

71:             time.sleep( 0.5 )  

72:         time.sleep( 1 )  

73:     # Redémarrer l’ESP  

74:     reset()  

75:  

76: if runapp.value() != 1:  

77:     from sys import exit  

78:     exit(0)  

79:  

80: led.value( 0 ) # allumer  

81:  

82: # --- Programme Pincipal ---  

83: from umqtt.simple import MQTTClient  

84: try:  

85:     q = MQTTClient( client_id = CLIENT_ID,  

86:         server = MQTT_SERVER,  

87:         user = MQTT_USER,  

88:         password = MQTT_PSWD )  

89:     if q.connect() != 0:  

90:         led_error( step=1 )  

91: except Exception as e:  

92:     print( e )  

93:     led_error( step=2 ) # check MQTT_SERVER, MQTT_USE-  

94:          MQTT_PSWD  

95:  

96: # chargement des bibliothèques  

97: try:  

98:     from ads1x15 import *  

99:     from machine import Pin  

100: except Exception as e:  

101:     print( e )  

102:     led_error( step=3 )  

103:  

104: # déclare le bus i2c  

105: i2c = I2C( sda=Pin(4), scl=Pin(5) )  

106:  

107:  

108: # créer les senseurs  

109: try:  

110:     adc = ADS1115( i2c=i2c, address=0x48, gain=0 )  

111:  

112:     contact = Pin( CONTACT_PIN, Pin.IN, Pin.PULL_UP )  

113:     last_contact_state = contact.value()  

114:     # lire la valeur de la LDR et  

115:     #    déterminer le dernier etat connu  

116:     last_ldr_state = ldr_to_state(  

117:         adc_ldr   = adc.read( rate=0, channel1=1),  

118:         adc_pivot = adc.read( rate=0, channel1=2) )  

119: except Exception as e:  

120:     print( e )  

121:     led_error( step=4 )  

122:  

123: try:  

124:     # annonce connexion objet  

125:     sMac = hexlify( WLAN().config( ’mac’ ) ).decode()  

126:     q.publish( "connect/%s" % CLIENT_ID , sMac )  

127: except Exception as e:  

128:     print( e )  

129:     led_error( step=5 )  

130:  

131: import uasyncio as asyncio  

132:  

133: def capture_1h():  

134:     """ Exécuté pour capturer des données chaque heure """  

135:     global q  

136:     global adc  

137:     # tmp36 - senseur température  

138:     valeur = adc.read( rate=0, channel1=0 )  

139:     mvolts = valeur * 0.1875  

140:     t = (mvolts - 500)/10  

141:     # transformer en chaine de caractères  

142:     t = "{0:.2f}".format(t)   

143:     q.publish( "maison/rez/veranda/temp", t )  

144:  

145: def check_contact():  

146:     """ Publie un message chaque fois que le contact change  

147:          d’état """  

148:     global q  

149:     global last_contact_state  

150:     # si rien n’a changé  

151:     if contact.value()==last_contact_state:  

152:         return  

153:     # état différent -> déparasitage logiciel  

154:     time.sleep( 0.100 )  

155:     # relire l’état et s’assurer qu’il n’a pas changé  

156:     valeur = contact.value()   

157:     if valeur != last_contact_state:  

158:         q.publish( "maison/rez/veranda/portefen",  

159:             "OUVERT" if valeur==1 else "FERME" )  

160:         last_contact_state = valeur  

161:  

162: def check_ldr():  

163:     global q  

164:     global adc  

165:     global last_ldr_state  

166:     ldr_state = ldr_to_state(  

167:         adc_ldr = adc.read( rate=0, channel1=1),  

168:         adc_pivot = adc.read( rate=0, channel1=2) )  

169:     if ldr_state != last_ldr_state:  

170:         q.publish( "maison/rez/veranda/ldr", ldr_state )  

171:         last_ldr_state = ldr_state  

172:  

173: def heartbeat():  

174:     """ Led éteinte 200ms toutes les 10 sec """  

175:     # PS: LED déjà éteinte par run_every!  

176:     time.sleep( 0.2 )  

177:  

178:  

179: async def run_every( fn, min= 1, sec=None):  

180:     """ Exécute la fonction fn toutes les minutes ou  

181:          secondes"""  

182:     global led  

183:     wait_sec = sec if sec else min*60  

184:     while True:  

185:         led.value( 1 ) # éteindre pendant envoi/traitement  

186:         try:  

187:             fn()  

188:         except Exception:  

189:             print( "run_every catch exception for %s" % fn)  

190:             raise # quitter loop  

191:         led.value( 0 ) # allumer  

192:         await asyncio.sleep( wait_sec )  

193:  

194: async def run_app_exit():  

195:     """ fin d’exécution lorsque quitte la fonction """  

196:     global runapp  

197:     while runapp.value()==1:  

198:         await asyncio.sleep( 10 )  

199:     return  

200:  

201: loop = asyncio.get_event_loop()  

202: loop.create_task( run_every(capture_1h, min=60) )  

203: loop.create_task( run_every(check_contact, sec=2 ) )  

204: loop.create_task( run_every(check_ldr, sec=5) )  

205: loop.create_task( run_every(heartbeat, sec=10) )  

206: try:  

207:     loop.run_until_complete( run_app_exit() )  

208: except Exception as e :  

209:     print( e )  

210:     led_error( step=6 )  

211:  

212: loop.close()  

213: led.value( 1 ) # eteindre  

214: print( "Fin!")

Le fonctionnement général de l’objet ayant déjà été décrit, cette section se concentre sur les éléments clés du script.

Création du convertisseur ADC ADS1115 en ligne 110 et initialisation de la broche CONTACT_PIN qui sera utilisée pour lire l’état du contact magnétique.

110:     adc = ADS1115( i2c=i2c, address=0x48, gain=0 )  

111:  

112:     contact = Pin( CONTACT_PIN, Pin.IN, Pin.PULL_UP )

Les lectures de la température sur le TMP36, de la luminosité (via la photorésistance) et du potentiomètre passent par le convertisseur ADC, raison de la création de l’objet adc en ligne 110.

Le script prévoit trois tâches pour répondre aux spécifications :

202: loop.create_task( run_every(capture_1h, min=60) )  

203: loop.create_task( run_every(check_contact, sec=2 ) )  

204: loop.create_task( run_every(check_ldr, sec=5) )

3. La fonction capture_1h()

La fonction capture_1h() publie la température du TMP36 toutes les heures.

133: def capture_1h():  

134:     """ Exécuté pour capturer des données chaque heure """  

135:     global q  

136:     global adc  

137:     # tmp36 - senseur température  

138:     valeur = adc.read( rate=0, channel1=0 )  

139:     mvolts = valeur * 0.1875  

140:     t = (mvolts - 500)/10  

141:     # transformer en chaine de caractère  

142:     t = "{0:.2f}".format(t)   

143:     q.publish( "maison/rez/veranda/temp", t ))

Lignes 135 et 136 : récupération des variables globales correspondant au ClientMQTT et au convertisseur ADS1115.

Ligne 138 : lecture de l’entrée analogique A0 du convertisseur sur laquelle est branché le senseur de température analogique TMP36.

Ligne 139 : conversion en millivolts. Le rapport de conversion de 0,1875 dépend du gain de l’amplificateur (voir ligne 110). Le rapport à utiliser est détaillé dans le chapitre ESP8266 sous MicroPython - Programmer.

Ligne 140 : conversion de la tension (mV) en température. Formule issue de la fiche technique du TMP36.

Ligne 142 : conversion sous forme de chaîne de caractères avec deux décimales.

Ligne 143 : publication du message sur le topic maison/rez/veranda/temp.

4. La fonction check_contact()

La fonction check_contact() publie le changement d’état du contact magnétique.

Pour commencer, l’état initial du contact magnétique est lu lors de l’initialisation de la broche CONTACT_PIN. Voir ligne 113.

112:     contact = Pin( CONTACT_PIN, Pin.IN, Pin.PULL_UP )  

113:     last_contact_state = contact.value()

La variable globale last_contact_state enregistre le dernier état connu du contact. La fonction check_contact (exécutée toutes les 2 secondes) vérifie l’état actuel de la broche par rapport au dernier état connu. Si l’état a changé, alors il faut envoyer le message correspondant.

145: def check_contact():  

146:     """ Publie un message chaque fois que le contact change  

147:          d’état """  

148:     global q  

149:     global last_contact_state  

150:     # si rien n’a changé  

151:     if contact.value()==last_contact_state:  

152:         return  

153:     # état différent -> déparasitage logiciel  

154:     time.sleep( 0.100 )  

155:     # relire l’état et s’assurer qu’il n’a pas changé  

156:     valeur = contact.value()   

157:     if valeur != last_contact_state:  

158:         q.publish( "maison/rez/veranda/portefen",  

159:             "OUVERT" if valeur==1 else "FERME" )  

160:         last_contact_state = valeur

Lignes 148 et 149 : récupération des variables globales correspondant au ClientMQTT et au dernier état connu du contact magnétique.

Lignes 151 et 152 : si l’état de la broche n’a pas changé depuis la dernière exécution de check_contact(), alors il n’est pas nécessaire d’envoyer de message au broker. L’exécution de la fonction s’achève par l’appel de l’instruction return.

Ligne 154 : déparasitage logiciel de l’entrée. Le contact magnétique peut avoir été rompu (ou rétabli) n’importe quand durant le délai entre deux appels consécutifs de check_contact(). Le changement d’état peut aussi avoir lieu au moment de l’appel de la fonction, dans ce cas, un certain nombre de rebonds peuvent avoir lieu durant l’éloignement ou l’approche de l’aimant. Un délai de 100 ms est plus approprié au changement d’état d’un contact magnétique alors que 10ms seront réservés au déparasitage d’un bouton poussoir.

Lignes 156 et 157 : relecture de l’état et exécution de la publication si l’état est effectivement changé.

Ligne 158 : publication du message correspondant à l’état du contact magnétique. L’entrée étant au niveau bas lorsque le contact est fermé, l’expression ternaire "OUVERT" if valeur==1 else "FERME" retourne « OUVERT » si la broche est au niveau haut et « FERME » dans le cas contraire.

Ligne 159 : mémoriser l’état courant comme dernier état connu pour éviter le renvoi du message lors de la prochaine exécution de check_contact().

5. La fonction check_ldr()

La fonction check_ldr() vérifie le niveau de luminosité de la photorésistance pour déterminer l’état « NOIR » ou « ECLAIRAGE ».

La fonction s’appuie sur la variable globale last_ldr_state pour mémoriser le dernier état connu et la fonction ldr_to_state() permet d’évaluer l’état correspondant à la mesure de la photorésistance.

La ligne 36 définit une constante d’hystérésis, cette dernière évite le basculement continuel entre les deux états NOIR et ECLAIRAGE lorsque la valeur analogique A1 retournée pour la photorésistance oscille autour de la valeur pivot.

36: LDR_HYST  = 200

À noter que la valeur pivot est définie à l’aide d’un potentiomètre sur l’entrée analogique A2 (valeur numérique entre 0 et 32767).

images/04RI95.pngimages/04RI95.png
 

Changement d’état et hystérésis

L’état ne bascule de « NOIR » à « ECLAIRAGE » (courbe bleue, de gauche à droite) que lorsque la valeur lue dépasse la valeur pivot de 200 unités, valeur définie dans LDR_HYST. De même, l’état ne bascule de « ECLAIRAGE » à « NOIR » (courbe rouge, de droite à gauche) que lorsque la valeur lue est inférieure de 200 unités à la valeur pivot.

Voici donc le détail de la fonction ldr_to_state( adc_ldr, adc_pivot ) qui détermine l’état « NOIR » ou « ECLAIRAGE » par rapport à la valeur de pivot.

39: def ldr_to_state( adc_ldr, adc_pivot ):  

40:     """ Transforme la valeur adc lue en NOIR et ECLAIRAGE  

41:          """  

42:     global last_ldr_state  

43:     # print( "adc_ldr, adc_pivot = %s, %s" %  

44:     #        (adc_ldr, adc_pivot) )  

45:     if adc_ldr > (adc_pivot+LDR_HYST):  

46:         return "ECLAIRAGE"  

47:     elif adc_ldr < (adc_pivot-LDR_HYST):  

48:         return "NOIR"  

49:     else:  

50:         return last_ldr_state

Ligne 39 : le paramètre adc_ldr contient la valeur lue pour la photorésistance. adc_pivot est la valeur de pivot fixée à l’aide du potentiomètre.

Ligne 42 : récupération de la variable globale contenant le dernier état connu de la photorésistance (cette valeur est également le message envoyé sur le broker).

Ligne 45 : si la valeur de la photorésistance est supérieure au pivot + hystérésis, alors l’état est inévitablement « ECLAIRAGE », valeur retournée à la ligne 46.

Ligne 47 : sinon, le code vérifie si la valeur de la photorésistance est inférieure à la valeur pivot - hystérésis auquel cas l’état est inévitablement « NOIR », valeur retournée à la ligne 48.

Lignes 49 et 50 : si les tests en lignes 45 et 47 échouent, alors la valeur de la photorésistance se situe dans la zone d’hystérésis autour de la valeur pivot. Dans ce cas, il n’y a pas de changement d’état et c’est le dernier état connu last_ldr_state qui est retourné.

 

Une fois les lignes 43 et 44 décommentées, elles affichent la valeur de la photorésistance et du pivot dans une session REPL. Cela permet de faciliter le réglage du pivot.

Il est maintenant possible de se pencher sur la fonction check_ldr() prenant en charge la vérification du changement d’état, ainsi que la publication de celui-ci sur le broker MQTT. La fonction check_ldr() est appelée toutes les 5 secondes par la boucle de traitement.

162: def check_ldr():  

163:     global q  

164:     global adc  

165:     global last_ldr_state  

166:     ldr_state = ldr_to_state(  

167:         adc_ldr = adc.read( rate=0, channel1=1),  

168:         adc_pivot = adc.read( rate=0, channel1=2) )  

169:     if ldr_state != last_ldr_state:  

170:         q.publish( "maison/rez/veranda/ldr", ldr_state )  

171:         last_ldr_state = ldr_state

Lignes 163 à 165 : récupération des variables correspondant au client MQTT, convertisseur ADS1115 et au dernier état connu de la photorésistance.

Ligne 166 : utilisation de la fonction ldr_to_state() pour évaluer l’état de la photorésistance résultant de la lecture de l’entrée analogique A1 correspondant à celle-ci (voir adc_ldr=) et de l’entrée analogique A2 correspondant à la valeur pivot fixée à l’aide du potentiomètre (voir adc_pivot=).

Ligne 169 : si l’état de la photorésistance a changé alors exécution de la publication du nouvel état ldr_state (ligne 170) et mémorisation du nouvel état comme dernier état connu (ligne 171).

6. Tester l’objet

Tester l’objet est relativement simple. En utilisant l’utilitaire mosquitto_sub, il est possible de capturer tous les messages publiés sur le broker à l’aide de la commande suivante :

mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017

Les détails de l’utilitaire sont abordés dans le chapitre Le broker MQTT à la section Test avec Mosquitto.org.

Les messages suivants apparaissent après la mise-sous-tension de l’objet. Ouvrir et fermer le contact magnétique, et couvrir la photo résistance pour voir d’autres messages complémentaires.

pi@pythonic:~ $ mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017 

 

connect/veranda 5ccf7fefafeb  

maison/rez/veranda/temp 18.63  

maison/rez/veranda/portefen FERME  

maison/rez/veranda/portefen OUVERT  

maison/rez/veranda/portefen FERME  

maison/rez/veranda/ldr NOIR  

maison/rez/veranda/ldr ECLAIRAGE  

maison/rez/veranda/portefen OUVERT

Si les messages n’apparaissent pas, rendez-vous dans la section Dépannage d’un objet IoT.

Objet 4 : Chaufferie

Le schéma suivant présente le montage réalisé pour la commande de la chaufferie. La commande est prise en charge par un relais qui permet d’activer le circuit de commande de la chaufferie.

Un senseur de température numérique DS18B20 permet de relever la température du circuit d’eau à intervalles réguliers.

L’interrupteur RunApp permet d’interrompre le fonctionnement de l’objet. Le fonctionnement de RunApp est abordé en détail dans le chapitre ESP8266 sous MicroPython .

images/04RI93.pngimages/04RI93.png
 

Le fonctionnement du relais peut être simulé par une LED comme indiqué sur le schéma suivant. 

images/04RI93b.pngimages/04RI93b.png
 

Utiliser une LED pour simuler le fonctionnement du relais

1. Téléverser les scripts

Le script est disponible dans le répertoire esp8266/chaufferie/main.py du dépôt GitHub de l’ouvrage.

Le fichier main.py doit être modifié pour fixer les paramètres correspondant à la configuration réseau actuelle.

Ci-dessous les paramètres à adapter dans le fichier main.py.

MQTT_SERVER = "192.168.1.210" 

# Mettre a None si pas utile 

MQTT_USER = ’pusr103’ 

MQTT_PSWD = ’21052017’

Les fichiers suivants pourront alors être téléversés sur la plateforme à l’aide d’un outil tel que RShell ou Ampy :

2. Fonctionnement du script

Conformément aux spécifications décrites dans le chapitre Le broker MQTT à la section Topics du projet :

Le topic maison/cave/chaufferie/cmd permet d’envoyer des commandes. Les commandes supportées sont :

À noter que l’objet doit rejeter deux demandes de changement d’état dans un même intervalle de 10 secondes. Les changements d’état de la chaudière sont communiqués par l’objet sur le topic maison/cave/chaufferie/etat.

Une sonde de température numérique DS18B20 permet de relever la température de l’eau du circuit de radiateurs toutes les heures. Température communiquée sur le topic maison/cave/chaufferie/temp-eau.

Une exception cependant, lors d’un changement d’état de la chaudière, la cadence de communication des relevés de température est réduite à 10 minutes pendant une heure.

01: # coding: utf8  

02: """ La Maison Pythonic - Object chaufferie v0.1  

03:  

04:     Envoi des données température et activation de la  

05:          chaufferie via  serveur MQTT  

06:  """  

07:  

08: from machine import Pin, reset  

09: import time  

10: from ubinascii import hexlify  

11: from network import WLAN  

12:  

13:  

14: CLIENT_ID = ’chaufferie’  

15:  

16: # Utiliser résolution DNS (serveur en ligne)  

17: # MQTT_SERVER = ’test.mosquitto.org’  

18: #  

19: # Utiliser IP si le Pi est en adresse fixe  

20: # (plus fiable sur réseau local/domestique)  

21: # MQTT_SERVER = ’192.168.1.220’  

22: #  

23: # Utiliser le hostname si Pi est en DHCP et que la propagation  

24: # du # hostname atteind le modem/router (voir aussi  

25: # gestion mDns sur router). # (pas forcément fiable sur  

26: # réseau domestique)  

27: # MQTT_SERVER = ’pythonic’  

28: #  

29: # Attention: MicroPython sous ESP8266 ne gère pas mDns!  

30:  

31: MQTT_SERVER = "192.168.1.210"  

32:  

33: # Mettre a None si pas utile  

34: MQTT_USER = ’pusr103’  

35: MQTT_PSWD = ’21052017’  

36:  

37: # redémarrage auto après erreur  

38: ERROR_REBOOT_TIME = 3600 # 1 h = 3600 sec  

39:  

40: # chaudière  

41: CHAUD_PIN = 13   # Broche activation relais chaudière.  

42: chaud     = None # objet Pin de la chaudière  

43: last_chaud_state = None # Dernier état connu  

44: # temps (sec) du dernier chg d’état  

45: last_chaud_state_time = 0  

46:  

47: # Senseur Temp. DS18B20  

48: DS18B20_PIN = 2   

49: ds          = None # class DS18x20  

50: ds_rom      = None # Adresse du ds18b20 sur le bus OneWire  

51:  

52: # --- Démarrage conditionnel ---  

53: runapp = Pin( 12,  Pin.IN, Pin.PULL_UP )  

54: led = Pin( 0, Pin.OUT )  

55: led.value( 1 ) # éteindre  

56:  

57: def led_error( step ):  

58:     global led  

59:     t = time.time()  

60:     while ( time.time()-t ) < ERROR_REBOOT_TIME:  

61:         for i in range( 20 ):  

62:             led.value(not(led.value()))  

63:             time.sleep(0.100)  

64:         led.value( 1 ) # éteindre  

65:         time.sleep( 1 )  

66:         # clignote nbr fois  

67:         for i in range( step ):  

68:             led.value( 0 )  

69:             time.sleep( 0.5 )  

70:             led.value( 1 )  

71:             time.sleep( 0.5 )  

72:         time.sleep( 1 )  

73:     # Redémarrage de l’ESP  

74:     reset()  

75:  

76: if runapp.value() != 1:  

77:     from sys import exit  

78:     exit(0)  

79:  

80: led.value( 0 ) # allumer  

81:  

82: # --- Programme Pincipal ---  

83: def sub_cb( topic, msg ):  

84:     """ fonction de rappel pour souscriptions MQTT """  

85:     # debogage  

86:     #print( ’-’*20 )  

87:     #print( topic )  

88:     #print( msg )  

89:      

90:     # bytes -> str  

91:     t = topic.decode( ’utf8’ )  

92:     m = msg.decode(’utf8’)  

93:     try:  

94:         if t == "maison/cave/chaufferie/cmd":  

95:             chaud_exec_cmd( cmd = m )  

96:     except Exception as e:  

97:         # Capturer TOUTE exception sur souscription  

98:         # Ne pas crasher check_mqtt_sub et  

99:         #    asyncio.run_until_complete et l’ESP!  

100:  

101:         # Info debug sur REPL  

102:         print( "="*20 )  

103:         print( "Subscriber callback (sub_cb) catch an  

104:              exception:" )  

105:         print( e )  

106:         print( "topic et message" )  

107:         print( t )  

108:         print( m )  

109:         print( "="*20 )  

110:  

111: from umqtt.simple import MQTTClient  

112: try:  

113:     q = MQTTClient( client_id = CLIENT_ID,  

114:         server = MQTT_SERVER,  

115:         user = MQTT_USER, password = MQTT_PSWD )  

116:     q.set_callback( sub_cb )  

117:  

118:     if q.connect() != 0:  

119:         led_error( step=1 )  

120:      

121:     q.subscribe( ’maison/cave/chaufferie/cmd’ )  

122: except Exception as e:  

123:     print( e )  

124:     # check MQTT_SERVER, MQTT_USER, MQTT_PSWD  

125:     led_error( step=2 )  

126:  

127: # chargement des bibliothèques  

128: try:  

129:     from onewire import OneWire  

130:     from ds18x20 import DS18X20  

131:     from machine import Pin  

132: except Exception as e:  

133:     print( e )  

134:     led_error( step=3 )  

135:  

136: # déclare le bus i2c (if any)  

137: #  

138: # i2c = I2C( sda=Pin(4), scl=Pin(5) )  

139:  

140: # créer les senseurs  

141: try:  

142:     # Senseur temp DS18B20  

143:     ds = DS18X20( OneWire(Pin(DS18B20_PIN)))  

144:     roms = ds.scan()  

145:     if len(roms) == 0:  

146:         raise Exception( ’ds18b20 not available!’)  

147:     ds_rom = roms[0]  

148:  

149:     # Chaudière - init à l’arrêt  

150:     chaud = Pin( CHAUD_PIN, Pin.OUT )  

151:     chaud.value( 0 )  

152:     last_chaud_state = "ARRET"  

153: except Exception as e:  

154:     print( e )  

155:     led_error( step=4 )  

156:  

157: try:  

158:     # annonce connexion objet  

159:     sMac = hexlify( WLAN().config( ’mac’ ) ).decode()  

160:     q.publish( "connect/%s" % CLIENT_ID , sMac )  

161:     # Annonce l’état  

162: except Exception as e:  

163:     print( e )  

164:     led_error( step=5 )  

165:  

166: import uasyncio as asyncio  

167:  

168: def chaud_exec_cmd( cmd ):  

169:     """ Exécuter la commande cmd sur la chaudière.  

170:         Modifie l état de la chaudière et faire  

171:         les notifications """         

172:     assert cmd in ("MARCHE","ARRET"), "Invalid chaud cmd"  

173:  

174:     global q  

175:     global last_chaud_state  

176:     global last_chaud_state_time  

177:     global chaud  

178:  

179:     # Si pas chg d état -> rien faire  

180:     if cmd == last_chaud_state:  

181:         return  

182:     # éviter plusieurs changements d’état en 10 sec  

183:     if (time.time()-last_chaud_state_time)<10:  

184:         q.publish( "maison/cave/chaufferie/etat",  

185:              "REJECT-CMD" ) # informer du refus  

186:         time.sleep(0.100)  

187:         q.publish( "maison/cave/chaufferie/etat",  

188:              last_chaud_state ) # Renvoyer l etat  

189:         return  

190:     # changement d’état  

191:     last_chaud_state = cmd  

192:     last_chaud_state_time = time.time() # en sec  

193:     # changer état relais  

194:     chaud.value( 1 if cmd == "MARCHE" else 0 )  

195:     # Notification MQTT du nouvel état  

196:     #   Etat = commande = ("ARRET","ARRET")  

197:     q.publish( "maison/cave/chaufferie/etat", cmd )  

198:     # Force la publication de la température maintenant!  

199:     capture_1h()  

200:  

201: def capture_1h():  

202:     """ Exécutée pour capturer la température chaque heure  

203:          """  

204:     global ds  

205:     global ds_rom  

206:     # ds18b20 - senseur température  

207:     ds.convert_temp()  

208:     time.sleep_ms( 750 )  

209:     valeur = ds.read_temp( ds_rom )  

210:     # transformer en chaine de caractères  

211:     t = "{0:.2f}".format(valeur)   

212:     q.publish( "maison/cave/chaufferie/temp-eau", t )  

213:  

214: def capture_10m():  

215:     """ Capture de la température toutes les 10min (mais  

216:         dans     l heure suivant un changement d état de  

217:         la chaudière) """  

218:     global last_chaud_state_time  

219:     # Dans les 3600 sec (1h) après  

220:     if last_chaud_state_time and  

221:         ( (time.time() - last_chaud_state_time) < 3600 ):  

222:         # exécution par la routine de capture  

223:         capture_1h()  

224:  

225: def heartbeat():  

226:     """ Led éteinte 200ms toutes les 10 sec """  

227:     # PS: LED déjà éteinte par run_every!  

228:     time.sleep( 0.2 )  

229:  

230: def check_mqtt_sub():  

231:     """ Traitement des messages venant de MQTT """  

232:     global q  

233:     # Appel wait_msg() non bloquant.  

234:     # Provoque l’appel de sub_cb() 

235:     q.check_msg() # prendre un message (si présent)  

236:  

237: async def run_every( fn, min= 1, sec=None):  

238:     """ Exécute la fonction fn toutes les minutes  

239:         ou secondes"""  

240:     global led  

241:     wait_sec = sec if sec else min*60  

242:     while True:  

243:         led.value( 1 ) # éteindre pendant envoi/traitement  

244:         try:  

245:             fn()  

246:         except Exception:  

247:             print( "run_every catch exception for %s" % fn)  

248:             raise # quitter loop  

249:         led.value( 0 ) # allumer  

250:         await asyncio.sleep( wait_sec )  

251:  

252: async def run_app_exit():  

253:     """ fin d’exécution lorsque la fonction quitte """  

254:     global runapp  

255:     while runapp.value()==1:  

256:         await asyncio.sleep( 10 )  

257:     return  

258:  

259: loop = asyncio.get_event_loop()  

260: loop.create_task( run_every(capture_1h    , min=60) )  

261: loop.create_task( run_every(capture_10m   , min=10) )  

262: loop.create_task( run_every(heartbeat     , sec=10 ) )  

263: loop.create_task( run_every(check_mqtt_sub, sec=2.5) )  

264: try:  

265:     # Annonce l’état initial  

266:     q.publish( "maison/cave/chaufferie/etat",  

267:          last_chaud_state )  

268:  

269:     # Exécution du scheduler  

270:     loop.run_until_complete( run_app_exit() )  

271: except Exception as e :  

272:     print( e )  

273:     led_error( step=6 )  

274:  

275: loop.close()  

276: led.value( 1 ) # eteindre  

277: print( "Fin!")

Le fonctionnement général de l’objet ayant déjà été décrit, cette section se concentre sur les éléments clés du script.

Déclaration de la fonction sub_cb( topic, msg ) en ligne 83 permettant de traiter les messages MQTT entrants, et les messages envoyés par le broker suite aux différentes souscriptions de l’objet. Les détails de cette fonction seront abordés ultérieurement.

La ligne 113 crée une instance du Client MQTT tandis que la ligne 116 assigne la fonction de rappel sub_cb pour tous les messages entrants.

83: def sub_cb( topic, msg ):  

84:     """ fonction de rappel pour souscriptions MQTT """  

...  

111: from umqtt.simple import MQTTClient  

112: try:  

113:     q = MQTTClient( client_id = CLIENT_ID,  

114:         server = MQTT_SERVER,  

115:         user = MQTT_USER, password = MQTT_PSWD )  

116:     q.set_callback( sub_cb )  

...

Création du réseau de senseurs de température OneWire DS18B20 en ligne 143 et identification de l’unique senseur DS18B20 en ligne 147.

La broche CHAUD_PIN, en ligne 150, est utilisée en sortie pour commander le relais de la chaudière qui y est raccordé. Initialisation de la broche et de la variable d’état last_chaud_state à l’arrêt.

142:     # Senseur temp DS18B20  

143:     ds = DS18X20( OneWire(Pin(DS18B20_PIN)))  

144:     roms = ds.scan()  

145:     if len(roms) == 0:  

146:         raise Exception( ’ds18b20 not available!’)  

147:     ds_rom = roms[0]  

148:  

149:     # Chaudière - init à l’arrêt  

150:     chaud = Pin( CHAUD_PIN, Pin.OUT )  

151:     chaud.value( 0 )  

152:     last_chaud_state = "ARRET"

Le script prévoit trois tâches pour répondre aux spécifications :

260: loop.create_task( run_every(capture_1h    , min=60) )  

261: loop.create_task( run_every(capture_10m   , min=10) )  

262: loop.create_task( run_every(heartbeat     , sec=10 ) )  

263: loop.create_task( run_every(check_mqtt_sub, sec=2.5) )

À noter que l’état de la chaudière est communiqué à son lancement, à la ligne 266 juste avant l’exécution de la boucle de traitement asynchrone.

264: try:  

265:     # Annonce l’état initial  

266:     q.publish( "maison/cave/chaufferie/etat",  

267:          last_chaud_state )  

268:  

269:     # Exécution du scheduler  

270:     loop.run_until_complete( run_app_exit() )  

271: except Exception as e :  

272:     print( e )  

273:     led_error( step=6 )

3. La fonction capture_1h()

La fonction capture_1h() publie la température du DS18B20 toutes les heures.

201: def capture_1h():  

202:     """ Exécutée pour capturer la température chaque heure  

203:          """  

204:     global ds  

205:     global ds_rom  

206:     # ds18b20 - senseur température  

207:     ds.convert_temp()  

208:     time.sleep_ms( 750 )  

209:     valeur = ds.read_temp( ds_rom )  

210:     # transformer en chaine de caractères  

211:     t = "{0:.2f}".format(valeur)   

212:     q.publish( "maison/cave/chaufferie/temp-eau", t )

Lignes 204 et 205 : récupération des variables globales correspondant au ClientMQTT et au senseur DS18B20.

Ligne 207 : demande de capture de température sur le bus OneWire.

Ligne 208 : attente du temps réglementaire nécessaire à la réception des données sur le bus OneWire.

Ligne 209 : récupération de la température pour le senseur ds_rom (unique senseur du bus identifié sur le bus (voir ligne 147).

Ligne 211 : conversion sous forme de chaîne de caractères avec deux décimales.

Ligne 212 : publication du message sur le topic maison/cave/chaufferie/temp-eau.

4. La fonction capture_10m()

La fonction capture_10m() permet de communiquer un relevé de température toutes les 10 minutes durant l’heure suivant un changement d’état de la chaudière.

214: def capture_10m():  

215:     """ Capture de la température toutes les 10min (mais  

216:         dans     l heure suivant un changement d état de  

217:         la chaudière) """  

218:     global last_chaud_state_time  

219:     # Dans les 3600 sec (1h) après  

220:     if last_chaud_state_time and  

221:         ( (time.time() - last_chaud_state_time) < 3600 ):  

222:         # exécution par la routine de capture  

223:         capture_1h()

Ligne 218 : récupération de la variable globale last_chaud_state_time mémorisant l’heure du dernier changement d’état de la chaudière.

Ligne 220 : test en deux parties qui vérifie que la variable last_chaud_state_time est assignée (cette valeur est non assignée au démarrage du script étant donné qu’il n’y a pas encore eu de changement d’état).

Ligne 221 : suite du test vérifiant que l’on se situe dans l’intervalle d’une heure après le dernier changement d’état. La soustraction time.time() - last_chaud_state_time retourne le nombre de secondes écoulées depuis last_chaud_state_time.

Ligne 223 : publication de la température en utilisant la fonction de capture horaire.

5. La fonction check_mqtt_sub()

La fonction check_mqtt_sub() vérifie la présence d’un message toutes les 2.5 secondes.

230: def check_mqtt_sub():  

231:     """ Traitement des messages venant de MQTT """  

232:     global q  

233:     # Appel wait_msg() non bloquant.  

234:     # Provoque l’appel de sub_cb() 

235:     q.check_msg() # prendre un message (si présent)

Ligne 232 : récupération de variable globale du Client MQTT.

Ligne 235 : l’appel de check_msg() vérifie s’il y a un message MQTT à traiter. Cet appel est non bloquant, ce qui signifie que la fonction check_msg() termine immédiatement son exécution s’il n’y a pas de message. Dans le cas contraire, le message sera chargé et la fonction de rappel sub_cb() sera appelée pour traiter le contenu du message.

6. La fonction sub_cb()

La fonction sub_cb() est appelée par le client MQTT lorsqu’un message est reçu. Cette fonction est appelée pour toutes les souscriptions du client MQTT et doit donc prendre en charge les différents cas de figure.

83: def sub_cb( topic, msg ):  

84:     """ fonction de rappel pour souscriptions MQTT """  

85:     # débogage  

86:     #print( ’-’*20 )  

87:     #print( topic )  

88:     #print( msg )  

89:      

90:     # bytes -> str  

91:     t = topic.decode( ’utf8’ )  

92:     m = msg.decode(’utf8’)  

93:     try:  

94:         if t == "maison/cave/chaufferie/cmd":  

95:             chaud_exec_cmd( cmd = m )  

96:     except Exception as e:  

97:         # Capturer TOUTE exception sur souscription  

98:         # Ne pas crasher check_mqtt_sub et  

99:         #    asyncio.run_until_complete et l’ESP!  

100:  

101:         # Info debug sur REPL  

102:         print( "="*20 )  

103:         print( "Subscriber callback (sub_cb) catch an  

104:              exception:" )  

105:         print( e )  

106:         print( "for topic and message" )  

107:         print( t )  

108:         print( m )  

109:         print( "="*20 )

Lignes 85-86 : lignes à décommenter pour afficher le contenu du topic et le message reçu par l’objet dans une session REPL. Pratique pour vérifier les messages entrants.

Lignes 91-92 : transformation du topic et message du type bytes (tableau d’octets) en chaîne de caractères.

Ligne 94 : vérifie s’il s’agit d’un message de commande pour la chaudière (donc publié sur le topic « maison/cave/chaufferie/cmd ».

Ligne 95 : le message de commande m est passé en paramètre à la fonction chaud_exec_cmd() qui traite l’exécution de la commande.  

Lignes 96-109 : utilisation d’une section except pour capturer et étouffer toutes les exceptions pouvant survenir dans la fonction de rappel. Si une exception atteignait le client MQTT, cela interromprait la boucle de traitement asynchrone et le fonctionnement de l’objet. Étouffer l’erreur permet de maintenir en fonction les autres fonctionnalités de l’objet. Affichage du message d’exception, du topic et message MQTT dans la session REPL.

7. La fonction chaud_exec_cmd()

La fonction chaud_exec_cmd() est appelée par la fonction de rappel sub_cb() lorsqu’un message de commande pour la chaudière est reçu. Cette fonction prend en charge le traitement de la commande.

168: def chaud_exec_cmd( cmd ):  

169:     """ Exécuter la commande cmd sur la chaudière.  

170:         Modifie l état de la chaudière et faire  

171:         les notifications """         

172:     assert cmd in ("MARCHE","ARRET"), "Invalid chaud cmd"  

173:  

174:     global q  

175:     global last_chaud_state  

176:     global last_chaud_state_time  

177:     global chaud  

178:  

179:     # Si pas chg d état -> rien faire  

180:     if cmd == last_chaud_state:  

181:         return  

182:     # éviter plusieurs chg état en 10 sec  

183:     if (time.time()-last_chaud_state_time)<10:  

184:         q.publish( "maison/cave/chaufferie/etat",  

185:              "REJECT-CMD" ) # informer du refus  

186:         time.sleep(0.100)  

187:         q.publish( "maison/cave/chaufferie/etat",  

188:              last_chaud_state ) # Renvoyer l’état  

189:         return  

190:     # chg d’état  

191:     last_chaud_state = cmd  

192:     last_chaud_state_time = time.time() # en sec  

193:     # changer état relais  

194:     chaud.value( 1 if cmd == "MARCHE" else 0 )  

195:     # Notification MQTT du nouvel état  

196:     #   Etat = commande = ("ARRET","ARRET")  

197:     q.publish( "maison/cave/chaufferie/etat", cmd )  

198:     # Force la publication de la temperature maintenant!  

199:     capture_1h()

Ligne 172 : vérifie que le message de commande cmd est dans les valeurs admissibles. Dans le cas contraire, l’instruction assert lève une exception AssertionError.

Lignes 174-177 : récupération des variables globales. Respectivement : le client MQTT, le dernier état connu de la chaudière (last_chaud_state), l’heure à laquelle le dernier état a été fixé (last_chaud_state_time) et la broche de commande de la chaudière (chaud).

Lignes 180-181 : terminer l’exécution de la fonction si la commande demande de placer la chaudière dans l’état dans lequel elle est déjà.

Lignes 183-189 : vérifie si le temps écoulé depuis le dernier changement d’état est bien supérieur à 10 secondes. Si ce n’est pas le cas, la fonction publie un message « REJECT-CMD » avant de republier l’état actuel de la chaudière. Enfin l’exécution de la fonction se termine.

Lignes 191-192 : toutes les conditions de rejet ayant été écartées, il faut maintenant exécuter l’application du nouvel état. Pour commencer, le nouvel état cmd est mémorisé comme dernier état connu dans last_chaud_state. Enregistrement de l’heure actuelle comme heure de dernier changement d’état (last_chaud_state_time). À partir de maintenant, la fonction capture_10m() pourra publier la température toutes les 10 minutes pendant une heure.  

Ligne 194 : application du nouvel état sur la broche de sortie. L’expression ternaire 1 if cmd == "MARCHE" else 0 retourne 1 si le message de commande contient « MARCHE ». Cela aura pour effet d’activer la broche et le relais qui y est branché. Dans le cas contraire, l’expression retournera 0 et le relais sera désactivé.

Ligne 197 : publication du nouvel état sur le topic maison/cave/chaufferie/etat.

Ligne 199 : publier immédiatement un premier relevé de température dans la foulée.

8. Tester l’objet

Tester l’objet est relativement simple. En utilisant l’utilitaire mosquitto_sub, il est possible de capturer tous les messages publiés sur le broker à l’aide de la commande suivante :

mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017

Les détails de l’utilitaire sont abordés dans le chapitre Le broker MQTT à la section Test avec Mosquitto.org.

Les messages suivants apparaissent après la mise sous tension de l’objet.

pi@pythonic:~ $ mosquitto_sub -h pythonic.local -t "#" -v -u pusr103 -P 21052017 

 

connect/chaufferie 5ccf7fefb1d3  

maison/cave/chaufferie/etat ARRET  

maison/cave/chaufferie/temp-eau 21.56

Si les messages n’apparaissent pas, rendez-vous dans la section Dépannage d’un objet IoT.

Il est possible de modifier l’état de la chaudière depuis une seconde session de terminal en envoyant des messages sur le topic maison/cave/chaufferie/cmd avec l’utilitaire mosquitto_pub.

mosquitto_pub -h pythonic.local -t "maison/cave/chaufferie/cmd" -m "MARCHE" 

-u pusr103 -P 21052017

Ce qui produit les messages complémentaires suivants sur le terminal affichant les messages avec l’utilitaire mosquitto_sub.

maison/cave/chaufferie/cmd MARCHE  

maison/cave/chaufferie/etat MARCHE  

maison/cave/chaufferie/temp-eau 21.63  

maison/cave/chaufferie/temp-eau 21.63  

maison/cave/chaufferie/temp-eau 28.19  

maison/cave/chaufferie/temp-eau 28.44  

maison/cave/chaufferie/temp-eau 31.00  

maison/cave/chaufferie/temp-eau 32.25

L’envoi d’une commande incorrecte comme « TEST » avec l’utilitaire mosquitto_pub :

mosquitto_pub -h pythonic.local -t "maison/cave/chaufferie/cmd" -m "TEST" 

-u pusr103 -P 21052017

Ne produit aucun message complémentaire suivant sur le terminal affichant les messages avec l’utilitaire mosquitto_sub. La commande est ignorée.

Si une session REPL est ouverte, alors les messages suivants y seront visibles.

====================  

Subscriber callback (sub_cb) catch an exception:  

Invalid chaud cmd  

for topic and message  

maison/cave/chaufferie/cmd  

TEST  

====================

Dépannage d’un objet IoT

Si le test préliminaire d’un objet ne produit aucune publication sur le broker MQTT, alors les quelques points suivants peuvent vous aider à cerner et corriger le problème.

Problème

Cause

Solution

La LED reste éteinte

  • •.RunApp en position arrêt 

  • •.Fichier boot.py absent ou incorrect 

  • •.Syntaxe du fichier main.py incorrect 

Vérifier la position de l’interrupteur RunApp.

Vérifier ensuite le fichier boot.py et la connectivité au réseau Wi-Fi. cf. ESP8266 sous MicroPython - Séquence de démarrage MicroPython.

Établir une session REPL via la connexion série et presser le bouton Reset. Les erreurs de compilations du script seront affichées dans la session REPL.

La LED clignote rapidement

Code d’erreur produit par le script en cours d’exécution

Voir la grille « Led de statuts » en début de chapitre (cf. Informations pratiques). Cette liste établit une correspondance entre le code d’erreur, la section du script la produisant et les solutions.

Établir une session REPL via la connexion série et presser le bouton Reset. Les erreurs et les messages seront affichés dans la session REPL.

La LED rapporte systématiquement un code d’erreur 4 ou 6.

Code d’erreur produit par le chargement des bibliothèques des senseurs et la communication avec ces senseurs.

Si le montage contient des senseurs I2C ou SPI alors vérifier :

  • •.le câblage des senseurs 

  • •.la compatibilité des bibliothèques 

Désactiver l’objet avec l’interrupteur RunApp, redémarrer l’objet en pressant le bouton Reset et conduire un test du senseur à partir d’une session REPL.

Si le senseur répond correctement au test alors l’erreur provient du script main.py. Une exécution surveillée par une session REPL fournira un message d’erreur plus précis.

Plus de HeartBeat (LED s’éteint 200 ms toutes les 10 secondes).

Le script a été arrêté avec l’interrupteur RunApp.

Placer l’interrupteur RunApp en position de marche et presser le bouton Reset.

L’objet se reconnectera sur le réseau Wi-Fi puis le broker MQTT.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Introduction

À ce stade du document, les différents objets envoient des données vers le broker MQTT. Il est possible de suivre les différentes publications en utilisant l’utilitaire mosquitto_sub avec la commande suivante :

mosquitto_sub -h pythonic.local -t "#" -u pusr103 -P 21052017 -v

Pour rappel, la connexion sur le broker requiert l’utilisation d’un login (pusr103) et du mot de passe correspondant. Les détails de la commande mosquittto_sub sont abordés dans le chapitre relatif au broker MQTT (cf. Le broker MQTT - Configurer le login du broker MQTT).

1. Pourquoi utiliser une base de données ?

Ce chapitre se penche sur le stockage permanent des informations en base de données, ce que n’offre pas une solution comme Mosquitto. Le broker supporte des fonctionnalités MQTT avancées comme la rétention de messages et les clients persistants (cf. Le broker MQTT - La rétention de messages et Les clients persistants), mais ces options avancées ne résistent pas au redémarrage du système.

En effet, une fois le broker redémarré, il faudra attendre que tous les objets aient envoyé des données pour avoir un état général du système dans son ensemble.

Le stockage en base de données permet d’obtenir rapidement un état général du système. Ces informations peuvent certes être obsolètes, mais cela est parfois préférable au manque d’information.

Le stockage en base de données permet :

images/05RI01.pngimages/05RI01.png
 

Utilité d’une base de données couplée à un broker MQTT

2. Quel moteur de base de données ?

Le fait d’utiliser un Raspberry Pi restreint le choix des moteurs de base de données aux options open sources. Le moteur sélectionné doit être léger, car il doit cohabiter avec un broker MQTT et une application web.

Bien que MySQL/MariaDB soient des options de choix pour un Raspberry Pi, un moteur plus léger comme SQLite sera plus intéressant dans le cas présent.

Alors que MySQL est plutôt basé sur un modèle client-serveur, SQLite intègre le moteur de base de données directement dans le programme hôte sans pour autant exclure les accès concurrents, qui sont gérés au niveau du système de fichier.

SQLite est supporté par Python 2.7 (et Python 3) ainsi que par le projet Flask (serveur web et moteur de rendu en Python qui sera utilisé dans ce projet).

Compte tenu des ressources limitées sur un Raspberry Pi, SQLite est à la fois un choix pertinent et idéal pour épauler le broker MQTT.

3. Principe de fonctionnement de push-to-db

Ce chapitre se penche sur le développement du script Python push-to-db.py.

images/05RI02.pngimages/05RI02.png
 

Principe de fonctionnement de push-to-db

Le script push-to-db.py :

Une petite introduction à SQLite s’impose avant d’entrer dans les détails de fonctionnement du script.

SQLite 3

1. Présentation

images/05RI03.pngimages/05RI03.png
 

Logo du projet SQLite

SQLite, produit par la société Hwaci (https://www.hwaci.com/), est un moteur de base de données relationnelle open source rapide, léger et fiable. Pesant à peine 300 Kio, SQLite est intégré directement dans le programme hôte. Il ne nécessite donc pas de serveur de base de données.

SQLite utilise un fichier unique pour stocker la totalité de la base de données et s’appuie sur le système de fichiers pour gérer les accès concurrents. Il n’est donc pas conseillé de stocker la base de données sur un lecteur réseau.

SQLite est une base de données répondant au standard ACID assurant une parfaite gestion des transactions et du traitement atomique de celles-ci. SQLite implémente aussi une grande partie du standard SQL-92 permettant de manipuler la base de données avec des requêtes SQL.

Au contraire des moteurs de base de données traditionnels, SQLite n’utilise pas un typage des données statique et rigide (integer, numeric, varchar, etc.), mais un système de type dynamique. SQLite utilise des « classes de stockage » et un système de typage dynamique permettant d’assurer la compatibilité avec la plupart des moteurs et requêtes SQL des bases de données traditionnelles.

Le typage dynamique de SQLite permet de réaliser des opérations qui ne sont pas possibles avec un moteur au typage rigide, il offre également une plus grande souplesse pour le stockage des informations dans la base de données.  

Ces différents éléments ont fait de SQLite un outil de choix pour embarquer une base de données au sein d’un logiciel. SQLite est adopté par de nombreux logiciels connus comme Firefox, Skype, Adobe, McAfee ainsi que sur différentes plateformes (Linux, Windows, Mac) et systèmes embarqués (iPhone, Android).

À noter cependant que si SQLite supporte les accès concurrents, cela n’a rien à voir avec la robustesse d’un moteur relationnel de type client-serveur comme MySQL/MariaDB, PostgreSql et autres moteurs commerciaux.

Étant donné que SQLite est destiné à l’intégration d’une base de données au sein même d’un logiciel, ce dernier n’est pas alourdi par la mise en œuvre d’une gestion de rôle et de droit d’accès. Le seul droit d’accès pouvant être contrôlé est celui relatif au système de fichiers sur lequel est stocké le fichier de base de données.

2. Classe de stockage, type de données et affinité

Au contraire des bases de données traditionnelles, SQLite n’utilise pas un typage statique.

Dans une base de données standard, le type de donnée est défini sur chaque colonne et ce typage rigide est appliqué pour toutes les données stockées dans la colonne. Par exemple, il n’est pas question de stocker une chaîne de caractères dans une colonne numérique. Dans le même esprit, il n’est pas possible de stocker plus de 50 caractères si la colonne a été définie pour recevoir au maximum 50 caractères (ex. : varchar(50)).

A contrario, SQLite utilise un système dynamique où le typage de données est stocké avec la donnée elle-même. Ainsi, SQLite est capable de stocker une donnée texte dans une colonne destinée à recevoir des nombres réels. De même, SQLite ne prévoit pas de typage statique (type, format et longueur fixe). Par conséquent, il est possible de stocker une chaîne de caractères, quelle que soit sa longueur. SQLite adapte l’espace de stockage en fonction de la donnée à stocker. Cette approche permet de réaliser des opérations totalement impossibles sur une base de données traditionnelle.

SQLite utilise des classes de stockage et un traitement des affinités de type pour atteindre cette souplesse tout en assurant une compatibilité SQL avec les moteurs traditionnels.

a. Classe de stockage

Une classe de stockage permet de définir le typage de données de façon générale. À titre d’exemple, la classe de stockage INTEGER permet de stocker tous les types d’entiers (il existe six différents types d’entiers). Dans une base de données traditionnelle, un TINYINT stocke une valeur entre 0 et 255 (strictement dans cette gamme de valeur). SQLite sera plus souple et acceptera des entiers supérieurs à 255, quitte à adapter la classe de stockage.

Si le stockage de l’information dans le fichier de base de données de SQLite est optimisé (longueur adaptée pour un stockage optimal), cette information reste identifiée comme une classe de stockage INTEGER (entier) une fois l’information rechargée en mémoire.

Classe de données

Description

INTEGER

Entier signé. Peut être stocké sur 1 à 8 octets en fonction de la valeur.

Les valeurs booléennes sont stockées comme des entiers : 0 = False, 1 = True.

REAL

Valeur en virgule flottante. Stockée en respectant le format IEEE 8 octets.

TEXT

Chaîne de caractères, stockée en utilisant l’encodage de la base de données : UTF-8, UTF-16BE ou UTF16LE.

BLOB

Stockage de données binaires en l’état.

NULL

Sert à mentionner une valeur non assignée, dite « NULL ».

Dans la plupart des cas, la notion de classe de stockage et le type de données peuvent être interchangés sans aucune conséquence ! Cette souplesse permet par ailleurs à SQLite d’interpréter les requêtes SQL standards (SQL-92) de façon totalement transparente.
 
 

En SQLite, il est possible de stocker n’importe quelle classe de stockage dans n’importe quelle colonne. Cette souplesse n’est cependant pas applicable pour une colonne INTEGER PRIMARY KEY utilisée pour identifier les enregistrements de façon univoque (colonne où une chaîne de caractères ne sera pas la bienvenue).

b. Stockage des date et heure

Le moteur de SQLite ne dispose pas de classe stockage pour les informations de type date et heure. À la place, SQLite propose des fonctions de conversions permettant de transformer les types date et heure vers les classes de stockages TEXT, REAL et INTEGER.

Conversion vers

Format de stockage utilisé

TEXT

Utilise le format ISO8601 « AAAA-MM-JJ HH:MM:SS.SSS » (année, mois, jour, heures, minutes, secondes, millisecondes)

REAL

Nombre de jours dans le calendrier julien (Rome antique). Le nombre de jours depuis le 24 nov. 4714 avant Jésus-Christ, minuit à Greenwich. La partie entière représente un nombre de jours, la partie décimale le temps écoulé.

INTEGER

Temps écoulé, en secondes, depuis le 01/01/1970 00:00:00 UTC. Correspond à l’encodage du temps Unix.

Le projet stocke l’information d’horodatage sous ce format.

Voyez la page suivante pour plus d’informations sur les fonctions de conversions :
https://www.sqlite.org/lang_datefunc.html
 

c. Affinité de type pour les colonnes

Pour rappel, en SQLite n’importe quel type de donnée peut être stocké dans n’importe quelle colonne, peu importe la classe de stockage utilisée pour maintenir l’information en mémoire.

Ce fonctionnement est en contraste avec les moteurs de base de données relationnels traditionnels qui forcent eux la conversion vers le type de donnée mentionné pour la colonne.

Dans le cadre d’un moteur traditionnel, si la donnée ne peut pas être convertie vers le type de la colonne alors le moteur génère une erreur. Dans le cas de SQLite, l’information sera stockée dans la classe de stockage correspondant au format de la donnée avec identification de la classe de stockage.

L’« affinité de type » est un concept SQLite qui permet de définir un type préférentiel de donnée sur une colonne. Cela indique au moteur SQLite le type recommandé à utiliser pour stocker l’information. Si SQLite ne peut pas s’y conformer, alors il choisira la classe de stockage la plus appropriée.

Cela permet à SQLite de maximiser la compatibilité avec les moteurs de base de données traditionnel.

L’affinité des colonnes est déduite lors de la création de la table. En fonction du type utilisé dans la requête create table, SQLite optera pour une affinité plutôt qu’une autre.

Le tableau ci-dessous reprend les différentes affinités :

L’affinité TEXT

Les colonnes avec affinité TEXT permettent de stocker les données avec une classe de stockage NULL, TEXT ou BLOB. Les données numériques sont converties en texte avant le stockage.

L’affinité NUMERIC

Les colonnes avec affinité NUMERIC sont destinées à recevoir des valeurs numériques (avec point décimal) sans perte de précision ! Une colonne avec affinité NUMERIC peut contenir des valeurs en utilisant cinq classes de stockage.

Lorsqu’une classe de stockage TEXT est convertie pour être stockée dans une colonne à affinité NUMERIC, SQLite convertit le texte en INTEGER (ou REAL en seconde chance) si cette conversion est réversible et sans perte. Une conversion TEXT vers REAL est considérée réversible s’il est possible de préserver les 15 premières décimales. Si la conversion TEXT vers INTEGER ou REAL n’est pas possible, alors l’information sera stockée en utilisant la classe de stockage TEXT.

À noter que si une classe de stockage contient un texte contenant une valeur convertible sans perte vers une colonne avec affinité INTEGER (ex. : 125.0) alors la conversion vers INTEGER sera privilégiée.

L’affinité INTEGER

L’affinité INTEGER fonctionne comme l’affinité NUMERIC et provient surtout d’opérations de casting.

L’affinité REAL

L’affinité REAL, tout comme INTEGER, fonctionne comme l’affinité NUMERIC. À noter que cette affinité force la représentation des entiers ou celle équivalente en virgule flottante.

L’affinité BLOB

Une colonne avec cette affinité ne présente aucune préférence relative à la classe de stockage. Le moteur SQLite n’essaye pas de forcer l’utilisation d’une classe de stockage par rapport à une autre.

d. Résolution de l’affinité de type

L’affinité de type d’une colonne est déterminée au moment de la création de la table à l’aide de la requête SQL create table.

Voici les règles suivies par le moteur SQLite :

1.

Un type déclaré contenant INT produira une affinité INTEGER.

2.

Un type déclaré contenant CHAR, CLOB ou TEXT produira une affinité TEXT.

3.

Un type déclaré contenant BLOB (ou déclaré sans type) produira une affinité BLOB.

4.

Un type déclaré contenant REAL, FLOAT ou DOUB produira une affinité REAL.

5.

Sinon, par défaut, l’affinité est « NUMERIC ».

Exemples :

3. Affinité, expressions, comparaison et tri

Le résultat final d’un tri de valeurs ou d’expressions dépend aussi de l’affinité ! Une affinité NUMERIC produit un ordre 1, 3, 10, 20, 25, alors qu’une affinité TEXT produit un ordre 1, 10, 20, 25, 3 pour les mêmes éléments.

Il est donc important de connaître ces notions pour aiguiller les recherches en cas de problème.

a. Affinité des expressions

Une expression est une valeur littéralement incluse dans une requête SQL ou le résultat d’une sous-requête. Par conséquent, l’affinité de type de l’expression est un élément important puisque cette dernière à une implication sur le système de typage dynamique de SQLite et par conséquent le stockage dans la table.

L’affinité de l’expression est déduite comme suit :

1.

Si l’opérande à droite d’un opérateur IN (ou NOT IN) est une liste, alors il n’y a pas d’affinité.

2.

Si l’opérande à droite d’un opérateur IN (ou NOT IN) est le résultat d’un SELECT, alors l’affinité est identique au résultat du SELECT.

3.

Lorsque l’expression est une référence vers une colonne d’une table, alors l’affinité est identique à celle de la colonne de la table.

4.

Lorsque l’expression implique un opérateur sur une colonne d’une table, alors il n’y a plus d’affinité sur le résultat de l’expression.

 

À noter que la seule utilisation de parenthèses n’implique pas de modification d’affinité ! L’affinité de ma_colonne et de (ma_colonne) reste identique. Par contre, ma_colonne a une affinité, alors que +ma_colonne n’a plus d’affinité.

5.

Une opération de casting ( cast(expression as type) ) impose l’affinité du résultat. 

6.

Si aucun des points précédents n’est applicable à l’expression, alors cette dernière n’a pas d’affinité.

La fonction typeof()

SQLite propose une fonction typeof() fort utile pour identifier l’affinité d’une expression ou d’une valeur stockée dans une table.

La session SQLite3 en ligne de commande, voir ci-dessous, permet de vérifier l’affinité des informations stockées dans une table.

$ sqlite3  

SQLite version 3.8.7.1 2014-10-29 13:59:56  

Enter ".help" for usage hints.  

Connected to a transient in-memory database.  

Use ".open FILENAME" to reopen on a persistent database.  

sqlite> create table demo(  

  ...> a_text TEXT,  

  ...> a_num NUMERIC,  

  ...> a_int INTEGER,  

  ...> a_real REAL,  

  ...> a_blob BLOB );  

sqlite> -- insérer des valeurs comme entier  

sqlite> insert into demo  

  ...> values( 123, 123, 123, 123, 123 );  

sqlite> select * from demo;  

123|123|123|123.0|123  

sqlite> select typeof(a_text), typeof(a_num),  

  ...> typeof(a_int), typeof(a_real),  

  ...> typeof(a_blob) from demo;  

text|integer|integer|real|integer  

sqlite> .quit

Bien que les valeurs stockées soient des entiers (valeur 123), la fonction typeof() démontre que :

Un exercice intéressant est de reproduire ce test en insérant des valeurs avec différentes affinités de type pour voir si celles-ci sont prises en compte par le moteur SQLite.

-- Affinité TEXT  

insert into demo values( ’123.5’, ’123.5’, ’123.5’, ’123.5’, ’123.5’ );  

-- les colonnes ...  

--    a_text|a_num|a_int|a_real|a_blob  

-- produisent les typeof() suivants ...  

--    text|real|real|real|text  

 

-- Affinité REAL  

insert into demo values( 123.5, 123.5, 123.5, 123.5, 123.5 );  

-- les colonnes ...  

--    a_text|a_num|a_int|a_real|a_blob  

-- produisent les typeof() suivants ...  

--    text|real|real|real|real  

 

-- Affinité BLOB  

-- insertion de données encodées en hexadécimal  

insert into demo values( x’A0F3’, x’A0F3’, x’A0F3’, x’A0F3’, x’A0F3’ );  

-- les colonnes ...  

--    a_text|a_num|a_int|a_real|a_blob  

-- produisent les typeof() suivants ...  

--    blob|blob|blob|blob|blob  

 

-- Affinité NULL  

insert into demo values( NULL, NULL, NULL, NULL, NULL );  

-- les colonnes ...  

--    a_text|a_num|a_int|a_real|a_blob  

-- produisent les typeof() suivants ...  

--    null|null|null|null|null

b. Comparaison, tri et groupage

SQLite dispose de l’ensemble des opérateurs de comparaison que l’on retrouve habituellement sur un moteur de base de données (=, ==, <, >, <=, >=,!=, IN, NOT IN, BETWEEN, IS, IS NOT).

SQLite utilisant des classes de stockage et autorisant le typage dynamique des valeurs stockées, il est assez facile d’avoir une situation où les valeurs comparées appartiennent à des classes de stockage différentes.

Cela a un impact sur les tests de comparaison, mais également sur l’ordre de tri (clause ORDER BY) et sur l’agrégation de données (clause GROUP BY)

Voici les règles de comparaison qui s’appliquent :

1.

Une classe de stockage NULL est toujours inférieure à n’importe quelle autre valeur/classe de stockage (y compris une autre classe de stockage NULL).

2.

Une classe de stockage REAL ou INTEGER est inférieure à une classe de stockage TEXT ou BLOB.

3.

Une classe de stockage REAL ou INTEGER utilise une comparaison numérique si l’autre opérante est un REAL ou INTEGER.

4.

Une classe de stockage TEXT est inférieure à une classe de stockage BLOB.

5.

Une classe de stockage TEXT est comparée à une autre classe TEXT par comparaison des chaînes de caractères en utilisant la séquence de collation appropriée (BINARY, NOCASE, RTRIM).

6.

Lorsque la comparaison fait intervenir deux classes de stockage BLOB, SQLite utilise une comparaison binaire à l’aide de la fonction memcmp().

Conversion de type avant comparaison

Dans certains cas, SQLite effectue une conversion de type avant la comparaison. Cela concerne les classes de stockage INTEGER, REAL et TEXT et s’applique en suivant les règles suivantes :

1.

Si un opérande est INTEGER, REAL ou TEXT et que l’autre opérande est TEXT, BLOB ou sans affinité, alors ce second opérande est converti en affinité NUMERIC avant comparaison.

2.

Si un opérande est TEXT et que l’autre opérande n’a pas d’affinité, alors ce second opérande est converti en affinité TEXT avant comparaison.

Dans le cas contraire, aucune conversion d’affinité n’est appliquée avant comparaison.

Opérations de tri

Durant les opérations de tri (clause ORDER BY), les classes de stockage sont ordonnées comme suit :

Opérations de regroupement

Durant les opérations de regroupement (clause GROUP BY), les valeurs ayant des classes de stockage différentes sont considérées comme des valeurs différentes (sauf pour les INTEGER et REAL).

4. Clé primaire et auto-incrément

a. Définir une clé primaire

Une clé primaire est une colonne (ou un groupe de colonnes) qui permet d’identifier un enregistrement de façon unique dans la table.

Il y a deux façons de définir une clé primaire :

L’exemple suivant utilise une contrainte de colonne pour définir une clé primaire sur la colonne id :

create table ts_salon (  

 id integer primary key,  

 topic text,  

 message text,  

 qos integer,  

 rectime integer  

);

L’exemple ci-dessous utilise une contrainte de table pour définir une contrainte couvrant deux colonnes :

create table demo_pk (  

 id_1 not null integer,  

 id_2 not null integer,  

 infotext text,  

 primary key(id_1, id_2)  

);

Selon le standard SQL en vigueur, une clé primaire ne peut pas contenir de valeur NULL. Cependant, afin de rester compatible avec les précédentes versions de SQLite, celui-ci acceptera une valeur NULL.

 

À noter que si la colonne clé primaire est précisément de type INTEGER, alors l’insertion d’une valeur NULL provoque l’auto-incrément de la colonne (voir table rowid ci-dessous).

b. Table rowid et clé primaire

À moins de spécifier WITHOUT ROWID durant la création d’une table, SQLite crée une table dite « table rowid » en ajoutant implicitement une colonne rowid contenant un entier signé sur 64 bits.

Cette colonne rowid est utilisée pour identifier chaque enregistrement dans la table. Le contenu de cette colonne est automatiquement incrémenté à chaque insertion d’enregistrement.

Cette caractéristique est très pratique pour obtenir une table avec une clé auto-incrémentée.

Une « table rowid » stocke les données sous forme d’arbre B, une structure sous forme d’arbre équilibré, car la recherche et le tri d’enregistrements en utilisant un rowid sont très rapides.

Une colonne integer comme clé primaire

Si une table est définie avec une clé primaire sur une seule et unique colonne de type INTEGER (exactement INTEGER), alors SQLite crée un alias vers la colonne rowid.

Cela signifie qu’il n’est pas nécessaire de fixer une valeur pour la clé primaire lors de l’insertion d’un nouvel enregistrement. En effet, SQLite incrémente la valeur la colonne rowid, et par conséquent celle de la colonne de la clé primaire (puisque cette dernière est un alias vers rowid).

L’exemple suivant utilise SQLite3 en ligne de commande et démontre la création d’une ’table rowid’ avec une colonne id en alias implicite sur la colonne rowid. L’insertion d’une valeur NULL dans la colonne id n’empêche pas l’auto-incrémentation de sa valeur.

 $ sqlite3  

SQLite version 3.8.7.1 2014-10-29 13:59:56  

Enter ".help" for usage hints.  

Connected to a transient in-memory database.  

Use ".open FILENAME" to reopen on a persistent database.  

sqlite> create table ts_demo( id integer primary key,  

  ...> topic text );  

sqlite> insert into ts_demo values ( 1, "demo" );  

sqlite> insert into ts_demo values ( 1, "demo2" );  

Error: UNIQUE constraint failed: ts_salon.id  

sqlite> insert into ts_demo values ( 2, "atcg" );  

sqlite> insert into ts_demo values ( NULL, "auto-increment" );  

sqlite> select * from ts_demo;  

1|demo  

2|atcg  

3|auto-increment  

sqlite>

 

Créer une clé primaire en tant que INTEGER PRIMARY KEY DESC ne crée pas d’alias sur la colonne rowid. La clé primaire doit strictement être INTEGER PRIMARY KEY.

5. SQLite3 et accès concurrents

SQLite 3 introduit un nouveau système de verrouillage (lock en anglais) et de journalisation permettant d’améliorer les accès concurrents à la base de données. Ce système de verrouillage réduit également les problèmes de contention lors d’accès en écriture à la base de données.

C’est pour cette raison que le moteur SQLite 3 a été choisi de préférence à SQLite 2.

Du point de vue du processus, le fichier de base de données peut être dans l’un des cinq états de verrouillage suivants :

Verrouillage

Signification

UNLOCK

Il n’y a aucun verrouillage sur la base de données. La base de données n’est peut-être ni en lecture ni en écriture. Tous les autres processus peuvent lire ou écrire dans la base de données si leur propre état de verrouillage l’autorise. Toutes les données maintenues en cache sont considérées comme invalides et doivent faire l’objet d’une vérification avec les données contenues dans le fichier physique.

SHARED

La base de données peut être lue, mais pas utilisée en écriture. Plusieurs processus peuvent maintenir un verrouillage SHARED en même temps sur la base de données. Il peut donc y avoir plusieurs processus lisant le contenu de la base de données au même moment.

RESERVED

Un verrouillage RESERVED indique qu’un processus planifie prochainement une opération d’écriture alors qu’il effectue actuellement des opérations de lecture. Le moteur de base de données n’autorise qu’un seul verrouillage RESERVED à la fois. Ce verrouillage peut cependant coexister avec plusieurs verrouillages de type SHARED.

La particularité du verrouillage RESERVED est qu’il autorise le placement de nouveaux verrouillages SHARED pendant la détention du verrouillage RESERVED.

PENDING

Le verrouillage PENDING indique que le processus détenant le verrouillage désire accéder en écriture sur la base de données dans les plus brefs délais.

À la différence du verrouillage RESERVED, le moteur n’accepte plus de nouveaux verrouillages SHARED sur la base de données et attend la clôture des verrouillages SHARED actuellement actifs avant d’offrir l’accès en écriture.

Le verrouillage PENDING est l’étape intermédiaire permettant d’accéder au verrouillage EXCLUSIVE.

EXCLUSIVE

Le verrouillage EXCLUSIVE (exclusif) est requis pour effectuer une opération d’écriture sur la base de données. Un seul verrouillage EXCLUSIVE peut être détenu sur la base de données et plus aucun autre verrouillage ne peut coexister en même temps qu’un verrouillage EXCLUSIVE.

SQLite réduit le temps d’opération en verrouillage EXCLUSIVE au strict minimum afin de maximiser les accès concurrents sur la base de données.

La gestion des accès concurrents et des verrouillages est prise en charge par le module pager de SQLite. Ce module est responsable des transactions ACID (Atomique, Consistante, Isolée, Durable) et gère les interactions avec le système d’exploitation en s’appuyant sur les services proposés par celui-ci.
 

Ce point est important, car c’est également le système d’exploitation qui offre l’accès et les fonctions de verrouillage sur le système de fichiers.

SQLite utilise le verrouillage coopératif POSIX (POSIX advisory locks) pour implémenter le verrouillage sous Unix. Sous Windows, SQLite utilise les fonctions système LockFile(), LockFileEx() et UnlockFile().

Si les appels systèmes sont fiables pour les systèmes de fichiers natifs sur des disques locaux, ce n’est pas toujours le cas pour toutes les implémentations de systèmes de fichiers, et plus particulièrement pour les systèmes de fichiers réseau.

Une implémentation incorrecte ou boguée du verrouillage de fichier sur le système d’exploitation conduit inévitablement à une corruption de la base de données. En effet, rien n’assure que l’accès EXCLUSIVE l’est vraiment, ni que plusieurs processus n’accèdent pas en concurrence à des données en cours de modification (auquel cas, les données ne sont pas fiables, voire totalement corrompues).  

Par exemple, NFS est connu pour avoir des bogues d’implémentation POSIX advisory locks (voire certaines portions non implémentées). Plusieurs blocages ont également été reportés sur le système de fichiers réseau de Windows.

 

Il est vivement recommandé de ne pas utiliser SQLite sur un système de fichiers en réseau.

6. Installation

a. Installer SQLite 3

SQLite 3 est installé sur le Raspberry en saisissant la commande :

sudo apt-get install sqlite

Ce qui produit le résultat suivant confirmant l’installation du paquet SQLite 3 :

$ sudo apt-get install sqlite  

Reading package lists... Done  

Building dependency tree        

Reading state information... Done  

The following extra packages will be installed:  

 libsqlite0 sqlite3  

Suggested packages:  

 sqlite-doc sqlite3-doc  

The following NEW packages will be installed:  

 libsqlite0 sqlite sqlite3  

0 upgraded, 3 newly installed, 0 to remove and 65 not upgraded.  

Need to get 238 kB of archives.  

After this operation, 638 kB of additional disk space will be used.  

Do you want to continue? [Y/n]  

....  

Preparing to unpack .../libsqlite0_2.8.17-12_armhf.deb ...  

Unpacking libsqlite0 (2.8.17-12) ...  

Selecting previously unselected package sqlite.  

Preparing to unpack .../sqlite_2.8.17-12_armhf.deb ...  

Unpacking sqlite (2.8.17-12) ...  

Selecting previously unselected package sqlite3.  

Preparing to unpack .../sqlite3_3.8.7.1-1+deb8u2_armhf.deb ...  

Unpacking sqlite3 (3.8.7.1-1+deb8u2) ...  

Processing triggers for man-db (2.7.5-1~bpo8+1) ...  

Setting up libsqlite0 (2.8.17-12) ...  

Setting up sqlite (2.8.17-12) ...  

Setting up sqlite3 (3.8.7.1-1+deb8u2) ...  

Processing triggers for libc-bin (2.19-18+deb8u10) …

b. Installer le support Python

SQLite est déjà intégré à l’installation standard de Python 2.7. Cela peut facilement être vérifié en saisissant l’instruction import sqlite3 dans une session Python interactive.

pi@pythonic:~ $ python  

Python 2.7.9 (default, Sep 17 2016, 20:26:04)  

[GCC 4.9.2] on linux2  

Type "help", "copyright", "credits" or "license" for more information.  

>>> import sqlite3  

>>>

L’importation du module sqlite3 doit se dérouler sans erreur.

7. Premiers pas avec SQLite3

SQLite propose l’utilitaire sqlite3 qui est un interpréteur de commande pour SQLite 3. L’interpréteur accepte des requêtes SQL (terminées par un point-virgule) et des commandes non SQL (commençant par un point comme .open).

L’interpréteur SQLite présente une particularité, si aucun fichier de base de données n’est communiqué en paramètre au lancement de l’interpréteur de commande, alors une base de données temporaire est automatiquement créée en mémoire. Cette dernière est donc volatile, mais peut également être sauvée à l’aide de la commande .save.

La suite de cette section s’attelle à créer une base de données rudimentaire en utilisant l’interpréteur de commande. Par la suite, cette base de données sera manipulée à l’aide de Python.

Saisir la commande sqlite3 pour démarrer l’interpréteur de commande SQLite. Comme l’indique le message affiché à l’écran, une base de données temporaire est créée en mémoire.

pi@pythonic:~ $ sqlite3  

SQLite version 3.8.7.1 2014-10-29 13:59:56  

Enter ".help" for usage hints.  

Connected to a transient in-memory database.  

Use ".open FILENAME" to reopen on a persistent database.  

sqlite>

La requête suivante crée une table nommée fruits permettant de stocker un nom et le nombre de calories pour 100 g accompagnés d’un identifiant unique id automatiquement incrémenté. Pour rappel, une clé primaire sur une classe de stockage INTEGER devient automatiquement un alias sur le ROWID de la table.

pi@pythonic:~ $ sqlite3  

SQLite version 3.8.7.1 2014-10-29 13:59:56  

Enter ".help" for usage hints.  

Connected to a transient in-memory database.  

Use ".open FILENAME" to reopen on a persistent database.  

sqlite> create table fruits (  

  ...> id integer primary key,  

  ...> name text,  

  ...> kcal_100gr integer );  

sqlite>

Ensuite, des requêtes SQL permettent d’insérer des données. La requête SQL select permet de constater que les données sont insérées et l’incrémentation de la clé primaire.

sqlite> insert into fruits (name, kcal_100gr) values (’Abricot’, 43 );  

sqlite> insert into fruits (name, kcal_100gr) values (’Ananas’, 55 );  

sqlite> insert into fruits (name, kcal_100gr) values (’Banane’, 88 );  

sqlite> select * from fruits;  

1|Abricot|43  

2|Ananas|55  

3|Banane|88  

sqlite>

Pour finir, la base de données peut être sauvegardée à l’aide de la commande .save, puis utiliser .quit pour quitter l’interpréteur SQLite.

sqlite> .save /home/pi/food.db  

sqlite> .quit  

pi@pythonic:~ $ ls /home/pi/*.db  

/home/pi/food.db  

pi@pythonic:~ $

Il est également possible d’utiliser une syntaxe alternative en insérant null dans la colonne ROWID (ou sa colonne en alias). Le mécanisme d’auto-incrémentation du ROWID s’active comme attendu.

L’exemple suivant montre comment injecter une commande SQL dans l’interpréteur de commande SQLite ainsi que l’insertion du null dans le champ auto-incrémenté.

pi@pythonic:~ $ echo "insert into fruits values (NULL, ’Kiwi’, 51);" | 

sqlite3 /home/pi/food.db

 

Pour injecter un fichier SQL dans l’interpréteur SQL, il faut utiliser la syntaxe cat mon_fichier.sql | sqlite3 /home/pi/food.db.

La base de données ayant été sauvée dans le répertoire utilisateur sous le nom /home/pi/food.db, il est possible de démarrer l’interpréteur de commande SQLite en indiquant le fichier .db en paramètre (ou en l’ouvrant à l’aide de la commande .open de SQLite).

La commande sqlite3 /home/pi/food.db permet de ré-ouvrir la base de données dans l’interpréteur de commande.

Ensuite, la commande .tables affiche la liste des tables de la base de données. La commande .schema fruits affiche le schéma de la table fruits sous forme d’une requête SQL.

pi@pythonic:~ $ sqlite3 /home/pi/food.db  

SQLite version 3.8.7.1 2014-10-29 13:59:56  

Enter ".help" for usage hints.  

sqlite> .tables  

fruits  

sqlite> .schema fruits  

CREATE TABLE fruits (  

id integer primary key,  

name text,  

kcal_100gr integer );  

sqlite> select * from fruits;  

1|Abricot|43  

2|Ananas|55  

3|Banane|88 

4|Kiwi|51 

sqlite>

Pour finir, SQLite est compatible avec le standard du langage SQL même s’il omet partiellement ou complètement quelques fonctionnalités du langage.

a. Documentation SQL pour SQLite

Une documentation du langage SQL tel qu’il est interprété par SQLite 3 est disponible sur les liens suivants :

b. Commandes de l’interpréteur SQLite

Les commandes SQLite commencent par un point, comme par exemple .open.

Il est possible d’obtenir une liste de ces commandes à tout moment en saisissant .help dans l’interpréteur SQLite.

Voici quelques commandes SQLite parmi les plus importantes. Une documentation plus complète est disponible sur http://www.sqlite.org/cli.html.

Les paramètres optionnels sont entourés de point d’interrogation « ? »

Commande

Description

.help

Affiche les commandes supportées par l’interpréteur SQLite.

.databases

Liste les noms et les fichiers des bases de données attachées à l’interpréteur de commande SQLite.

.exit

Quitte l’interpréteur de commande SQLite.

.quit

Quitte l’interpréteur de commande SQLite.

.open FILE

Ouvre un fichier de base de données et ferme la base de données en cours d’utilisation.

.save FILE

Sauve la base de données chargée en mémoire dans un fichier de base de données.

.tables ?LIKE?

Sans paramètre, affiche la liste de toutes les tables de la base de données.

Le paramètre LIKE, optionnel, permet de saisir une expression SQL LIKE pour filtrer les tables en fonction de leur nom. Par exemple %ui% permet de lister les tables contenant « ui » dans leur nom.

.schema ?LIKE?

Permet d’inspecter le schéma de la base de données. Si le paramètre LIKE est mentionné, le schéma est limité aux éléments répondant à l’expression SQL LIKE.

Le schéma est retourné sous forme d’une expression SQL CREATE.

.indices ?LIKE?

Affiche les index de la base de données. Si le paramètre LIKE est mentionné, les index sont limités aux tables répondant à l’expression SQL LIKE.

.fullschema

Affiche le schéma complet de la base de données.

.dump ?LIKE?

Décharge le contenu de la base de données (schéma et données) au format SQL vers la sortie standard de l’interpréteur de commande.

Le paramètre LIKE, optionnel, permet de saisir une expression SQL LIKE pour filtrer les tables en fonction de leur nom.

.read FILENAME

Exécute les commandes SQL contenues dans le fichier SQL FILENAME. 

.show

Affiche les paramètres relatifs à la session.

Affiche, entre autres, la valeur des séparateurs de colonne et de ligne.

L’exécution de requêtes SQL affiche les données vers la sortie standard (le terminal).
 

La sortie peut être redirigée vers un fichier avec les commandes .once ou .output, détaillées ci-dessous.

Cette sortie utilise un séparateur de colonne (| par défaut) et le séparateur de ligne (\r\n par défaut, équivalent du retour chariot suivi d’une nouvelle ligne).

Ces séparateurs sont reconfigurables et utilisés pour toutes les sorties (fichier et terminal), mais aussi l’importation de données.

Voici les commandes permettant de manipuler le format de sortie, d’exportation et d’importation.

Commande

Description

.header VALUE

Permet d’afficher le nom des colonnes en première ligne de résultat d’une requête SQL de type select. VALUE peut avoir la valeur on ou off (off par défaut).

.mode VALUE

Permet de modifier le mode d’affichage des résultats des requêtes SQL de type select. Le mode par défaut est list.

  • •.column : affiche le résultat dans des colonnes justifiées à gauche. 

  • •.csv : affiche le résultat au format CSV, un format de donnée tabulaire avec les données séparées par des virgules. Ce format peut être facilement rechargé dans un tableur, un script Python et de nombreux autres logiciels. 

  • •.html : affiche le résultat avec la mise en forme d’une table HTML. 

  • •.insert : affiche le résultat sous forme de requêtes SQL de type insert. Format idéal pour transférer les données vers une autre base de données. 

  • •.line : affiche le résultat sous forme de clé = valeur (avec le nom de la colonne comme valeur de clé). Format appréciable pour une exploitation par des programmes (ex. : script Python). 

  • •.list : mode par défaut. Affiche les données séparées par un caractère « | » comme séparateur de colonne et un retour à la ligne (\r\n) comme séparateur d’enregistrement. Ce format d’affichage est très compact. 

  • •.Tabs : utilise un caractère de tabulation comme séparateur de données. 

  • •.Tcl : liste d’éléments pour le langage TCL. 

.separator COL ?ROW?

Permet de modifier le séparateur de colonne par défaut « | » et optionnellement le séparateur de ligne par défaut (\r\n). Ces séparateurs sont utilisés pour le mode de sortie (list) et par la commande .import.

.output ?FILE?

Envoie la sortie vers un fichier (ou la sortie standard si FILE est omis).

.once FILE

Envoie la sortie de la commande suivante (uniquement) vers le fichier mentionné dans FILE.

.import FILE TABLE

Importe un fichier de données au format mentionné par le mode (commande .mode) et les séparateurs (commande .separator) dans la table TABLE.

8. SQLite et Python
 
8. SQLite et Python

Voici quelques exemples de scripts Python permettant d’interagir avec une base de données SQLite3. Ces exemples utilisent la base de données food.db créée à la section précédente.

Une copie de cette base de données est disponible dans le répertoire python/divers/food.db du dépôt GitHub de l’ouvrage.

a. Opération de lecture SQLite

L’exemple suivant, disponible dans python/divers/test-food-select.py du dépôt GitHub de l’ouvrage indique comment accéder à une base de données SQLite 3 depuis un script Python.

La base de données food.db doit être présente dans le répertoire courant.

L’exemple peut être exécuté à l’aide de la commande python test-food-select.py.

01: # coding: utf-8  

02: """ DEMO - Lecture d’une base de données SQLite3.  

03:   

04:     Affiche le contenu de la table fruits (food.db).  

05: """  

06:  

07: import sqlite3 as sqlite  

08:  

09: dbfile = "food.db"  

10:  

11: conn = sqlite.connect( dbfile )  

12:  

13: cursor = conn.cursor()  

14:  

15: cursor.execute( "select * from fruits order by name")  

16: if rowcount == 0:  

17:     print( "Pas de données disponible" )  

18: else:  

19:     # Affiche le nom des colonnes  

20:     colnames = [item[0] for item in cursor.description ]  

21:     print( " | ".join( colnames ) )  

22:     print( ’-’*40 )  

23:     # Fetchall() une liste de tuple  

24:     # [(1, u’Abricot’, 43), (2, u’Ananas’, 55), ... ]  

25:     for row in cursor.fetchall():  

26:         # Afficher le données des tuples  

27:         print( "%s | %s | %s" % (row[0], row[1], row[2]) )  

28:  

29: print( "\r\r" )  

30: # Utiliser fetchone()  

31: cursor.execute(  

32:       """select name,kcal_100gr  

33:         from fruits  

34:         where name like ’p%’  

35:         order by id desc""" )  

36: row = cursor.fetchone()  

37: while row:  

38:     # row = (u’Pomme’, 52)  

39:     print( " | ".join(  

40:         # transforme tout en string  

41:         ["%s"%item for item in row])  

42:         )  

43:     row = cursor.fetchone()  

44:  

45:  

46: conn.close()  

47:  

48:  

49: if __name__ == "__main__":  

50:     pass;

 

Dans une approche de puriste, la chaîne de caractères unicode affichée en ligne 39 avec l’instruction print() devrait coder ladite chaîne avec l’encodage du terminal de sorte qu’elle puisse être affichée sans erreur de conversion sur le terminal. Il se trouve que la chaîne est encodée en UTF-8 (cf. stockage DB) et que le terminal de notre Pi est également en UTF-8. Il n’y a donc pas de problème de conversion, cela restant un cas particulier. Dans les autres cas, il faudrait utiliser print( ma_chaine_unicode.encode( encodage_du_terminal, ’ignore’ ) ) sachant que l’encodage du terminal peut être obtenu avec sys.stdout.encoding.

À propos de la List Comprehension

À la ligne 20, l’expression [item[0] for item in cursor.description] permet de transformer une structure complexe en liste de noms de colonnes. Liste qui peut, par la suite, être énumérée avec une instruction for.

Suite à la requête SQL « select * from fruits order by name », la propriété cursor.description retourne la structure suivante :

((’id’, None, None, None, None, None, None), (’name’, None, None, None, None,  

None, None), (’kcal_100gr’, None, None, None, None, None, None))

Dans l’expression item[0] for item in cursor.description, item vaut tour à tour (’id’, None, None, None, None, None, None) puis (’name’, None, None, None, None, None, None) et ainsi de suite pour chacun des éléments de cursor.description. 

Par conséquent, item[0] vaudra tour à tour ’id’, puis ’name’, puis ’kcal_100gr’.

Pour finir, les caractères [ et ] entourant l’expression reconstituent une liste avec les différents éléments collectés. Ainsi, [item[0] for item in cursor.description] produit le résultat [’id’, ’name’, ’kcal_100gr’].

b. Opération d’insertion SQLite

L’exemple suivant, disponible dans le fichier python/divers/test-food-insert.py du dépôt GitHub de l’ouvrage, indique comment accéder à une base de données SQLite 3 depuis un script Python.

La base de données food.db doit être présente dans le répertoire courant.

L’exemple peut être exécuté à l’aide de la commande python test-food-insert.py.

01: # coding: utf-8  

02: """ DEMO - Insertion dans une base de données SQLite3.  

03:   

04:     Insertions d’un enregistrement dans la table fruits  

05:          (food.db).  

06: """  

07:  

08: import sqlite3 as sqlite  

09:  

10: # capture un nom et un kcal/100gr  

11: fruit_name = raw_input( "Nom fruit: ")  

12: fruit_kcal = int( raw_input( "KCal/100gr: ") )  

13: reponse = raw_input( ’insérer (o/...)?’ )  

14: if reponse.lower().strip() != ’o’:  

15:     import sys  

16:     sys.exit(0)  

17:  

18: dbfile = "food.db"  

19:  

20: conn = sqlite.connect( dbfile )  

21: cursor = conn.cursor()  

22:  

23: cursor.execute(  

24:     "insert into fruits (name,kcal_100gr) values (?, ?)",  

25:     (fruit_name.decode(’UTF-8’), fruit_kcal)  

26:     )  

27: if cursor.rowcount > 0:  

28:     print( "Enregistrement inséré" )  

29:     print( "id = %s" % cursor.lastrowid )  

30:  

31: conn.commit()  

32:  

33: print( "\r\r" )  

34: # Affichage des enregistrements  

35: cursor.execute( "select * from fruits order by name" )  

36: if cursor.rowcount > 0:  

37:     colnames = [item[0] for item in cursor.description]  

38:     print( " | ".join(colnames) )  

39: for row in cursor.fetchall():  

40:     print("|".join(["%s"%value for value in row]))  

41:  

42: conn.close()  

43:  

44:  

45: if __name__ == "__main__":  

46:     pass;

L’exemple ci-dessus ne présente que quelques particularités par rapport au point précédent et seules les différences seront mises en lumière :

 

Les paramètres doivent toujours être communiqués sous forme de tuple ! Lorsqu’il n’y a qu’un seul paramètre alors la notation (val,) est utilisée pour forcer la création du tuple.

c. Row Factory de SQLite

Dans sa configuration par défaut, la méthode fetchone() du curseur SQL retourne un tuple avec une entrée par colonne sélectionnée. Et la méthode fetchall(), elle, retourne une liste de tuple.

import sqlite3 as sqlite  

dbfile = "food.db"  

conn = sqlite.connect( dbfile )  

cursor = conn.cursor()  

 

cursor.execute(  

     """select name,kcal_100gr  

        from fruits  

        where name like ’pomme’""" )  

 

row = cursor.fetchone() 

# row = (u’Pomme’, 52) 

# type( row ) → <type ’tuple’>  

nom = row[0]  

kcal = row[1] 

conn.close()

L’inconvénient de cette approche est que pour obtenir une valeur, il faut utiliser un index pour l’extraction du tuple. Si cela est très économe en mémoire, une erreur d’index ou une modification de la requête SQL retournera une autre valeur que celle attendue !

Modifier le row_factory

SQLite permet de modifier le row_factory, pour que les méthodes fetchall() et fetchone() retournent des objets sqlite3.Row plutôt que des tuples.  

import sqlite3 as sqlite  

dbfile = "food.db"  

conn = sqlite.connect( dbfile ) 

conn.row_factory = sqlite.Row 

cursor = conn.cursor()  

 

cursor.execute(  

     """select name,kcal_100gr  

        from fruits  

        where name like ’pomme’""" )  

 

row = cursor.fetchone()  

# row = (u’Pomme’, 52)  

# type( row ) = <type ’sqlite3.Row’>  

nom = row[’name’]  

kcal = row[’kcal_100gr’]  

 

conn.close()

Cette approche permet d’extraire la valeur d’un champ en utilisant son nom au lieu d’un index. Cette approche offre plus de souplesse, moins de risques d’erreur, mais est aussi moins performante (car il faut résoudre le nom de la colonne).

Voici qui termine l’introduction à SQLite 3, il est temps d’entrer dans le vif du sujet.

Approches techniques de push-to-db

Le rôle du script push-to-db.py est de collecter des messages MQTT pour les pousser dans une base de données SQLite3 (pythonic.db).

Le restant du chapitre décrit les différents éléments clés.

Le fonctionnement du script repose sur un fichier de configuration dont les informations sont utilisées pour instancier des objets à la volée. Ce fichier contient les noms de classes à créer, les topics à souscrire et autres informations. La partie logicielle s’appuie sur le fichier de configuration qui à son tour s’appuie sur la partie logicielle. C’est comme un serpent qui se mord la queue, difficile de savoir par quel bout commencer les explications.

Cette section est organisée comme suit :

1. Approche base de données de push-to-db

Dans l’approche utilisée pour le stockage des informations :

a. topicmsg - dernier message reçu

La table topicmsg est utilisée pour maintenir une copie du dernier message MQTT reçu pour un topic donné. Les messages sont communiqués par le broker MQTT suite aux différentes souscriptions réalisées par push-to-db.py.

images/05RI04.pngimages/05RI04.png
 

Schéma de la table topicmsg (réalisé avec Draw.IO)

Les champs sont les suivants :

 

Dans un modèle normalisé, le champ topic aurait pu être déclaré comme clé primaire pour la table (et assure aussi l’unicité de chaque enregistrement). Dans les faits, SQLite assurerait l’unicité du champ, mais utiliserait une colonne rowid interne comme clé d’enregistrement. Ce serait également ce rowid qui serait utilisé pour constituer l’arbre B en mémoire. En conséquence, autant utiliser un modèle apparent proche du modèle réel étant donné que SQLite ne constituera pas l’arbre B sur base du topic, mais bien sur le rowid (dont la colonne id est un alias).  

Topics collectés

Les topics suivants sont collectés dans la table topicmsg :

b. ts_xxx - historique de messages

Les tables ts_xxx sont destinées à maintenir des historiques des valeurs communiquées pour un ou plusieurs topics. Ces tables pouvant devenir relativement volumineuses en fonction de la fréquence et de la quantité des messages capturés. Plusieurs optimisations ont été prévues afin de ne pas surcharger la carte SD du Raspberry Pi (en termes de taille de fichier et d’opérations de lecture/écriture).

Voici le schéma général d’une table timeseries ts_xxx où xxx est remplacé par un nom identifiant clairement le contenu de la table.

images/05RI05.pngimages/05RI05.png
 

Schéma de la table ts_xxx (réalisé avec Draw.IO)

Les champs sont les suivants :

Topics collectés

Les topics suivants sont collectés dans des tables historiques.

Pour la table ts_cab destinée à recevoir l’historique des données météorologiques de la cabane de jardin :

Pour la table ts_salon destinée à recevoir l’historique du salon

Pour la table ts_chauf destinée à recevoir l’historique de la chaufferie

2. Approche logicielle de push-to-db

Le logiciel est architecturé de sorte à :

a. Diagramme des classes (partie 1)

images/05RI06.pngimages/05RI06.png
 

Classes de traitement des captures MQTT et de stockage des messages (réalisé avec Draw.IO)

SqliteConnector

La classe SqliteConnector (héritée de BaseConnector) a pour seule tâche d’offrir les services nécessaires au stockage d’un message MQTT (la paire topic et payload) sur un média que le connecteur est capable de manipuler. SqliteConnector sait donc comment accéder à une base de données Sqlite3.

Les services offerts sont :

 

Les services update_value et timeserie_append prennent le nom de la table comme premier paramètre. L’information est fournie par l’appelant qui sait, en fonction de sa configuration, dans quelle table les informations doivent être stockées. Il est en effet tout à fait envisageable que des valeurs de consommation électrique, eau, gaz soient volontairement séparées des informations de surveillance générale.

Ces services sont épaulés par des méthodes permettant de manipuler le média.

[connector.sqlitedb]  

class=SqliteConnector  

db=/var/local/sqlite/pythonic.db

 

Il est tout à fait possible d’envisager l’élaboration d’une autre classe de service de stockage comme CalcConnector. Cette dernière ouvrirait un fichier LibreOffice Calc, équivalent de Microsoft Excel, pour y stocker les informations. Dans ce cadre, les méthodes connect(), disconnect(), commit() permettent respectivement d’ouvrir, fermer, sauver le fichier Calc, alors que update_value()et timeserie_append() effectuent respectivement la mise à jour d’une valeur et l’ajout de valeurs dans un historique en utilisant le nom de la feuille calcul en guise de nom de table.  

L’implémentation de SqliteConnector se réduit aux quelques éléments suivants, sans oublier import sqlite3 as sqlite en début de script.

class SqliteConnector( BaseConnector ):  

 """ Connecteur pour DB Sqlite3 """  

 def __init__( self, params ):  

   super( SqliteConnector, self ).__init__( params )  

   # fichier de stockage de la DB  

   # typiquement /var/local/sqlite/pythonic.db  

   self.db_file = params[’db’]  

   # reference vers le moteur DB  

   self._conn = None  

 

 def is_connected( self ):  

   return (self._conn != None)  

 

 def connect( self ):  

   if not self.is_connected():  

     self._conn = sqlite.connect( self.db_file )  

 

 def disconnect( self ):  

   if self.is_connected():  

     self._conn.close()  

     self._conn = None  

 

 def commit( self ):  

   if self.is_connected():  

     self._conn.commit() # sauver les modifications 

  

 def update_value( self, table_name, receive_time, topic, 

                   payload, qos ):  

   """ Stocke la dernière valeur connue dans la table """  

   sSql = "UPDATE %s SET message = ?, qos = ?, rectime = ? where topic = ?" % table_name 

   self.connect()  

   cur = self._conn.cursor()  

   r = cur.execute( sSql, (payload, qos, receive_time, topic) )  

   # Si record pas encore mis-à-jour --> insérer  

   if r.rowcount == 0:  

     sSql = "INSERT INTO %s (topic,message,qos,rectime) VALUES (?,?,?,?)" % table_name 

     r = cur.execute( sSql, (topic,payload,qos,receive_time) )  

 

 def timeserie_append( self, table_name, receive_time, topic, 

                       payload, qos ):  

   """ Stocke l’historique de valeurs (timeseries) """  

   sSql = "INSERT INTO %s (topic,message,qos,rectime) VALUES (?,?,?,?)" % table_name 

   self.connect()   

   cur = self._conn.cursor()  

   r = cur.execute( sSql, (topic,payload,qos,receive_time) )

MqttBaseCapture

MqttBaseCapture est une classe de base destinée à être spécialisée, comme c’est le cas pour les classes MqttTopicCapture et MqttTimeserieCapture.

images/05RI06b.pngimages/05RI06b.png
 

Classe MqttBaseCapture et ses descendants (réalisé avec Draw.IO)

Une classe de capture permet :

L’opération de stockage proprement dite est prise en charge par la méthode store_data(...) qui accepte un message MQTT en paramètre. Le message MQTT est stocké dans une instance de la classe QueuedMessage. En effet, les messages MQTT sont temporairement empilés dans une queue FIFO en attente du traitement pour le stockage.

L’opération de stockage n’est pas implémentée dans la classe MqttBaseCapture, mais dans les classes dérivées MqttTopicCapture et MqttTimeserieCapture qui savent comment manipuler le connecteur (SqliteConnector) pour stocker l’information afin de réaliser le service de capture requis.

La classe MqttBaseCapture expose les propriétés et méthodes suivantes :

[mqtt.capture.1]  

subscribe=maison/exterieur/cabane/lux,maison/exterieur/jardin/hrel,maison/

exterieur/jardin/temp  

class=MqttTimeserieCapture 

storage=sqlitedb.ts_cab  

MqttTopicCapture

Le but de la classe MqttTopicCapture est de maintenir une copie de la dernière valeur reçue pour les topics souscrits.

L’implémentation de store_data() dans MqttTopicCapture est assez concise.

class MqttTopicCapture( MqttBaseCapture ):  

       """ Classe gérant la capture des messages et  

           stockage de la dernière valeur dans une table """  

 def __init__( self, subscribe_comalist,  

               storage_target, connector ):  

   super( MqttTopicCapture, self).__init__(  

               subscribe_comalist, storage_target, connector )  

 

 def store_data( self, queued_message ):  

   """ traite le message capturé pour l’enregistrer à  

       l’aide du connecteur! """ 

 

   # Le connecteur sait comment accéder à la table  

   self.connector.update_value( self.storage_table , 

                       queued_message.receive_time,  

                       queued_message.topic, 

                       queued_message.payload,  

                       queued_message.qos )

MqttTimeserieCapture

Le but de la classe MqttTimeserieCapture est d’ajouter une valeur dans un historique existant.

L’implémentation de store_data() est également très concise.

class MqttTimeserieCapture( MqttBaseCapture ):  

 """ Classe gérant la capture des messages et stockage de la 

     dernière valeur dans une table """  

 def __init__( self, subscribe_comalist,  

               storage_target, connector ):  

   super( MqttTimeserieCapture, self).__init__(  

               subscribe_comalist, storage_target, connector )  

 

   def store_data( self, queued_message ):  

     """ traite le message capturé pour l’enregistrer à l’aide  

         du connecteur!"""  

     # Le connecteur sait comment accéder à la table  

     self.connector.timeserie_append( self.storage_table, 

                       queued_message.receive_time,  

                       queued_message.topic, 

                       queued_message.payload,  

                       queued_message.qos )

b. Fichier de configuration de push-to-db

Avant de poursuivre avec le diagramme des classes et les explications concernant le fonctionnement global du script, il est important de préciser quelques éléments relatifs au fichier de configuration.

Le fichier de configuration utilise le format inifile rendu très populaire par le système d’exploitation Windows. Ce format scinde le fichier en sections, chaque section portant un nom entouré de crochets (ex. : [lazywriter] ). La section contient une ou plusieurs valeurs nommées et organisées sous la forme clé=valeur . (ex. : MaxQueueSize=10).

Le fichier de configuration est exploité pour instancier des objets à la volée, objets dont le nom de classe se trouve précisément repris dans le fichier de configuration.

Le script push-to-db.py utilise le fichier de configuration /etc/pythonic/push-to-db.ini pour configurer le fonctionnement du logiciel.

Celui-ci permet de définir :

L’exemple ci-dessous reprend une partie du fichier de configuration :

[connector.sqlitedb]  

class=SqliteConnector  

db=/var/local/sqlite/pythonic.db  

 

[mqtt.capture.0]  

subscribe=maison/rez/#,maison/exterieur/#,maison/cave/#  

class=MqttTopicCapture  

storage=sqlitedb.topicmsg  

 

[mqtt.capture.1]  

subscribe=maison/exterieur/cabane/lux,maison/exterieur/jardin/hrel,maison/

exterieur/jardin/temp  

class=MqttTimeserieCapture  

storage=sqlitedb.ts_cab  

 

[mqtt.capture.2]  

subscribe=maison/rez/salon/temp,maison/rez/salon/pir  

class=MqttTimeserieCapture  

storage=sqlitedb.ts_salon 

 

[mqtt.capture.3]  

subscribe=maison/cave/chaufferie/etat,maison/cave/chaufferie/temp-eau  

class=MqttTimeserieCapture  

storage=sqlitedb.ts_chauf

Les connecteurs

Les connecteurs sont définis dans des sections commençant par la chaîne « connector. » suivie d’un code d’identification (« sqlitedb » dans ce cas). Les connecteurs permettent d’accéder aux médias de stockage et offrent les services update_value(...) et timeserie_append(...) précédemment décrits.  

La section [connector.sqlitedb] ci-dessous définit un connecteur nommé « sqlitedb » dans la configuration.

[connector.sqlitedb]  

class=SqliteConnector  

db=/var/local/sqlite/pythonic.db

La configuration du connecteur précise la classe à utiliser (SqliteConnector) avec le paramètre class= ainsi que les paramètres à passer au constructeur de la classe (db=...). Les paramètres sont passés au constructeur sous forme de dictionnaire, il peut donc y avoir autant de paramètres que souhaité, qui doivent respecter la nomenclature clé=valeur.

Une fois le connecteur défini, son identifiant « sqlitedb » peut ensuite être utilisé dans la configuration des souscriptions.

Les souscriptions

Les souscriptions sont définies dans des sections commençant par la chaîne « mqtt.capture. » suivie d’un code d’identification.

Les sections de souscriptions définissent à la fois :

L’exemple ci-dessous reprend la capture d’une série de messages MQTT pour le stockage de la dernière valeur connue (dans la table topicmsg de la base de données identifiée par « sqlitedb »).

[mqtt.capture.0]  

subscribe=maison/rez/#,maison/exterieur/#,maison/cave/#  

class=MqttTopicCapture  

storage=sqlitedb.topicmsg

Les messages à capturer sont repris dans le paramètre subscribe=. Les différentes souscriptions y sont séparées par une virgule.

Le paramètre class= repend le nom de la classe à instancier pour prendre les messages souscrits en charge.

Pour finir, le paramètre storage= permet de mentionner le connecteur à utiliser (sqlitedb fait référence à la section [connector.sqlitedb] du fichier de configuration) et la table où l’information doit être stockée (la table topicmsg). Le nom de la table est utilisé par la classe MqttTopicCapture (cf. paramètre class=) pour réaliser le stockage du message. 

Cet autre exemple reprend la capture de messages MQTT pour le stockage dans une table d’historique :

[mqtt.capture.1]  

subscribe=maison/exterieur/cabane/lux,maison/exterieur/jardin/hrel,maison/

exterieur/jardin/temp  

class=MqttTimeserieCapture  

storage=sqlitedb.ts_cab

Le paramètre class= mentionne la classe MqttTimeserieCapture (classe dédiée au stockage d’historique).

Le paramètre storage= mentionne le nom de la table ts_cab (timeserie cabane) à l’aide du connecteur décrit dans la section [connector.sqlitedb].

Les topics à capturer sont mentionnés dans le paramètre subscribe= (liste séparée par des virgules).

c. Diagramme des classes (partie 2)

images/page313.pngimages/page313.png
 

Organisation de l’application push-to-do (réalisé avec Draw.io)

Le diagramme ci-dessus présente l’organisation générale du script push-to-db.py. Il est constitué des éléments suivants :

QueuedMessage

La classe QueuedMessage est utilisée pour stocker le contenu d’un message MQTT envoyé par le broker.

Une instance de la classe est créée pour chaque message MQTT reçu et devant être traité par une des classes dérivées de MqttBaseCapture (voir fichier de configuration ci-avant). Si un même message doit être traité par plusieurs classes MqttBaseCapture (par exemple MqttTopicCapture et MqttTimeserieCapture), alors plusieurs instances de QueuedMessage sont créées pour ce même message.

Les instances sont empilées dans une queue FIFO (First In First Out, premier entré premier sorti) jusqu’au moment du stockage de ceux-ci par le thread MessageLazyWriter.

class QueuedMessage( object ):  

 __slots__ = [’receive_time’, ’topic’, ’payload’,  

              ’sub_handler’, ’qos’]  

 

 def __init__(self, receive_time, topic, payload,  

              qos, sub_handler ):  

   self.receive_time = receive_time  

   self.topic = topic  

   self.payload = payload  

   self.qos = qos  

   # Une des classes MqttXxxCapture capable  

   # d’écrire le message en DB en utilisant son connecteur.  

   self.sub_handler = sub_handler

 

Comme indiqué dans le diagramme des classes, l’attribut sub_handler contient une référence vers une instance dérivée de MqttBaseCapture, instance destinée à réaliser l’opération de stockage pour le message MQTT reçu. MqttBaseCapture dispose d’un attribut connector (comme par exemple une instance SqlConnector) permettant d’accéder facilement aux outils de stockage. La présence de l’attribut sub_handler dans QueuedMessage simplifiera, par la suite, le code nécessaire pour exécuter l’opération de stockage du message.

Queue de QueuedMessage

Un petit aparté concernant la queue FIFO utilisée pour stocker les messages QueuedMessage. 

Cette queue créée par la classe applicative App utilise les extraits de code suivants :

from Queue import Queue 

... 

self.message_queue = Queue()

Le module Queue (renommé queue en Python3) implémente plusieurs types de queues avec la particularité de mettre en place toute la sémantique nécessaire permettant de gérer le verrouillage, ce qui permet d’échanger des informations en toute sécurité entre plusieurs threads (exécution multitâche). Le thread principal (processus) peut donc empiler des éléments en toute sécurité pendant que le thread MessageLazyWriter dépile les messages de la queue.

Le module Queue propose les classes suivantes :

MessageLazyWriter

Le thread MessageLazyWriter a pour tâche de surveiller la taille de la queue des messages et de lancer les opérations d’écriture à intervalle régulier en respectant diverses règles.

Le but du thread est de retarder la sauvegarde des messages pour permettre l’accumulation de ceux-ci avant de réaliser une opération d’écriture sur le support physique. Pour l’anecdote, le jeu de mot « Lazy Writer » signifie « écrivain paresseux ».

En effet, les messages MQTT arrivent sous forme d’un flux sporadique avec, ici et là, des communications de messages MQTT en salve lorsqu’un objet communique plusieurs relevés. Il y a donc un intérêt à accumuler quelques messages pour réduire le nombre d’opérations d’écritures intermittentes, car chacune d’entre elles peut potentiellement être bloquante (ex. : base de données SQLite), ce qui entrave des accès concurrents aux données stockées. Dans le cas de SQLite, il est préférable de concentrer les écritures en bloc pour minimiser le temps de blocage en écriture, temps durant lequel toute opération de lecture est interdite.

D’autre part, le stockage d’une base de données sur le système de fichiers de la carte SD représente un goulot d’étranglement pour les performances du système. S’il n’est pas possible d’opter pour un stockage sur un disque externe, alors il est préférable de réduire les opérations d’écriture sporadiques pour minimiser l’impact sur les performances générales du système.

Enfin, une carte SD dispose d’un nombre limité de cycles d’écritures par cellule. Bien que cette limite soit très élevée (approximativement 100 000 cycles d’écritures), cette dernière est atteignable plus ou moins rapidement par les systèmes informatiques. Encore une fois, différer l’écriture des messages permet de réunir un lot de modifications/altérations en une seule opération physique, ce qui réduit sensiblement le nombre de cycles d’écritures nécessaires sur le support physique.

Le thread MessageLazyWriter utilise les paramètres suivants du fichier de configuration /etc/pythonic/push-to-db.ini.

[lazywriter]  

MaxQueueLatency=120  

MaxQueueSize=10  

PauseAfterProcess=2

Paramètres MessageLazyWriter dans le fichier de configuration :

 

Un temps de latence MaxQueueLatency important peut représenter un « problème » lors de la communication de messages urgents tels que des alertes. Pour rappel, push-to-db.py réalise un stockage persistant de certaines informations, sa vocation n’est pas de remplacer le broker MQTT. Si un message d’alerte doit être communiqué rapidement à un processus, alors il est préférable que ledit processus effectue les souscriptions adéquates directement sur le broker MQTT afin d’être immédiatement alerté par le broker.  

Voici les détails de la classe MessageLazyWriter.

01: class MessageLazyWriter(threading.Thread):  

02:   def __init__( self, params, message_queue, connectors,  

03:        stopper_event ):  

04:     super( MessageLazyWriter, self ).__init__()  

05:     self.max_queue_latency   =  

06:          int(params[’maxqueuelatency’])  

07:     self.max_queue_size      = int(params[’maxqueuesize’])  

08:     self.pause_after_process =  

09:          int(params[’pauseafterprocess’])  

10:     # Queue FiFo synchronisée  

11:     self.message_queue = message_queue  

12:     # Liste des connecteurs  

13:     self.connectors = connectors  

14:     # Event pour signaler l’arrêt du thread  

15:     self.stopper_event = stopper_event  

16:  

17:     self.logger = logging.getLogger(’root’)  

18:  

19:   def run( self ):  

20:     self.logger.debug( ’LazyWriter thread started’)  

21:     # heure debut compte-à-rebours  

22:     latency_start = None  

23:      

24:     while not self.stopper_event.is_set():  

25:       if self.message_queue.empty():  

26:         latency_start = None  

27:         continue  

28:       else:  

29:         if latency_start == None:  

30:           self.logger.debug( ’LazyWriter latency_start set  

31:                to now’)  

32:           latency_start = datetime.datetime.now()  

33:  

34:         if self.message_queue.qsize() > self.max_queue_size:  

35:           self.logger.info(  

36:              ’LazyWriter queue size %s reached ->  

37:                   Process_message_queue.’  

38:              % self.max_queue_size )  

39:           self.process_message_queue()  

40:           latency_start = None  

41:           time.sleep( self.pause_after_process )  

42:         elif latency_start and (  

43:              (datetime.datetime.now()-latency_start  

44:                 ).seconds > self.max_queue_latency ):  

45:           self.logger.info(  

46:              ’LazyWriter latency %s sec reached ->  

47:                   Process_message_queue.’  

48:              % self.max_queue_latency )  

49:           self.process_message_queue()  

50:           latency_start = None  

51:           time.sleep( self.pause_after_process )  

52:  

53:     self.logger.debug( ’LazyWriter thread exit’)  

54:  

55:   def process_message_queue( self ):  

56:     con_list = []  

57:     pmq_logger = logging.getLogger(’pmq’)  

58:     while not self.message_queue.empty():  

59:       queued_message = self.message_queue.get()  

60:       try:  

61:         pmq_logger.debug( ’process_message_queue %s ’ +  

62:               ’for handler %s on %s with payload %s’ %  

63:               (queued_message.topic,  

64:                queued_message.sub_handler.__class__.__name__  

65:                    ,  

66:                queued_message.sub_handler.target_id(),  

67:                queued_message.payload) )  

68:         # Collecter les connecteur mis-en-oeuvre  

69:         connector = queued_message.sub_handler.connector  

70:         if not connector in con_list:  

71:           con_list.append( connector )  

72:         queued_message.sub_handler.store_data(  

73:              queued_message )  

74:       except Exception as err:  

75:         pmq_logger.error(  

76:            ’process_message_queue encounter an’ +  

77:            ’ error while processing the message’ )  

78:         pmq_logger.error( ’  %s with %s’ % (  

79:             err.__class__.__name__, err) )                 

80:         pmq_logger.error( ’  handler: %s’ %  

81:             queued_message.sub_handler.__class__.__name__ )  

82:         pmq_logger.error( ’  topic  : %s’ %  

83:              queued_message.topic )  

84:         pmq_logger.error( ’  payload: %s’ %  

85:              queued_message.payload )  

86:       finally:  

87:         self.message_queue.task_done()  

88:         # Faire un commit sur tous les connecteurs  

89:         for connector in con_list:  

90:           connector.commit()  

91:           connector.disconnect()  

Méthode __init__( self, params, message_queue, connectors, stopper_event )

Méthode run( self )

Méthode process_message_queue( self )

images/05RI08.pngimages/05RI08.png
 

Accéder au connecteur depuis le message empilé (réalisé avec Draw.io)

App

La classe App gère les différents éléments de l’application et leur mise en œuvre.

Par exemple :

Le code de la classe App se présente comme suit :

01: class App:  

02:   def __init__(self):  

03:     self.logger = logging.getLogger(’root’)  

04:     self.logger.info( ’Initializing app’)  

05:  

06:     self.mqtt = None  

07:     self.message_queue = None  

08:     self.connectors = None  

09:     self.sub_handlers = None  

10:     # Event utilisé pour arrêter les Thread  

11:     self.stopper = None   

12:  

13:     self.config = Config( INIFILE )  

14:     self.build_connectors()  

15:     self.build_sub_handlers()  

16:  

17:   def build_connectors( self ):  

18:     self.connectors = {} # Réinitialiser le dict.  

19:     # collecter la liste des sections "connector.xxxx"  

20:     lst = self.config.search_section( "^connector\.\w+$" )  

21:     for section in lst:  

22:       connector_classname = self.config.get(  

23:          section, ’class’)  

24:       # reference vers la classe  

25:       connector_class = globals()[connector_classname]  

26:       # créer la classe et l’enregistrer  

27:       # sous son nom simple  

28:       self.connectors[  

29:          section.replace(’connector.’,’’)  

30:                      ] = \  

31:          connector_class( self.config.sections[section] )  

32:  

33:   def build_sub_handlers( self ):  

34:     self.sub_handlers = [] # Réinitialiser la liste  

35:     # collecter la liste des sections "mqtt.capture.x"  

36:     lst = self.config.search_section(  

37:          "^mqtt\.capture\.\d+$" )  

38:     for section in lst:  

39:       handler_classname = self.config.get( section, ’class’  

40:            )  

41:       # reference vers la classe  

42:       handler_class = globals()[handler_classname]  

43:       # Identifier l’instance du connecteur  

44:       connector_name = self.config.get(  

45:           section, ’storage’).split(’.’)[0]  

46:       if not(connector_name in self.connectors):  

47:         raise Exception(  

48:           ’No [connector.%s] defined for storage=%s (see  

49:                [%s])’%  

50:           (connector_name,  

51:               self.config.get( section, ’storage’),  

52:               section)  

53:         )  

54:       connector = self.connectors[connector_name]  

55:       self.sub_handlers.append(  

56:         # créer une instance de la classe  

57:         handler_class(  

58:            self.config.get( section, ’subscribe’ ),  

59:            self.config.get( section, ’storage’),  

60:            connector  

61:         )  

62:       )  

63:  

64:   def _mqtt_on_connect( self, client, userdata, flags, rc ):  

65:     self.logger.info( "mqtt connect return code: %s" % rc )  

66:     self.mqtt_connected = (rc == 0)  

67:  

68:   def _mqtt_on_message( self, client, userdata, message ):  

69:     self.logger.info( "getting MQTT message..." )  

70:     self.logger.info( "  topic  : %s" % message.topic )  

71:     self.logger.info( "  message: %s" % message.payload )  

72:     self.logger.info( "  QoS    : %s" % message.qos )  

73:     try:  

74:       to_call = {} # sub handler to call  

75:       for sub_handler in self.sub_handlers:  

76:         if sub_handler.match_subscription( message.topic ):  

77:           # Identifier la destination de sauvegarde  

78:           # pour éviter de sauvegarder plusieurs   

79:           # fois le message dans la même table 

80:           #  

81:           target_id = sub_handler.target_id()  

82:           if not( target_id in to_call ):  

83:             to_call[target_id] = sub_handler  

84:  

85:       for target_id, sub_handler in to_call.items():  

86:         to_queue = QueuedMessage(  

87:             receive_time=datetime.datetime.now(), \  

88:             topic=message.topic, payload=message.payload, \  

89:             qos=message.qos, sub_handler=sub_handler  )  

90:         self.message_queue.put( to_queue )  

91:  

92:     except Exception as err:  

93:       self.logger.error(  

94:           ’Exception while processing MQTT message’)  

95:       self.logger.error( "  topic: %s" % message.topic )  

96:       self.logger.error( "  message: %s" % message.payload )  

97:       self.logger.error( "  exception: %s" % err )  

98:  

99:   def connect_broker( self ):  

100:     if self.mqtt:  

101:       del( self.mqtt )  

102:       self.mqtt = None  

103:     self.mqtt_connected = False  

104:  

105:     self.mqtt = mqtt_client.Client(  

106:         client_id = ’push-to-db’ )  

107:     self.mqtt.on_connect = self._mqtt_on_connect  

108:     self.mqtt.on_message = self._mqtt_on_message  

109:     if not( self.config.get(  

110:               ’mqtt.broker’, ’username’, default = None  

111:             ) in (None, ’None’) ):  

112:       self.mqtt.username_pw_set(  

113:         username = self.config.get( ’mqtt.broker’,  

114:              ’username’),  

115:         password = self.config.get( ’mqtt.broker’,  

116:              ’password’)  

117:       )  

118:     self.mqtt.connect(  

119:         host = self.config.get( ’mqtt.broker’,  

120:              ’mqtt_broker’ ),  

121:         port = self.config.getint( ’mqtt.broker’,  

122:              ’mqtt_port’ ),  

123:         keepalive = self.config.getint( ’mqtt.broker’,  

124:              ’mqtt_keepalive’)  

125:     )  

126:  

127:     # effectue toutes les souscriptions nécessaires  

128:     sub_done = []  

129:     for sub_handler in self.sub_handlers:  

130:       for sub in sub_handler.sub_filters:  

131:         # Ne pas faire deux fois la même souscription  

132:         if not sub in sub_done:  

133:           self.logger.info( ’subscribing %s’ % sub )  

134:           self.mqtt.subscribe( sub )  

135:           sub_done.append( sub )  

136:  

137:   def run( self ):  

138:     self.logger.info( ’Running app’)  

139:     self.message_queue = Queue()  

140:     self.stopper = threading.Event()  

141:  

142:     # Thread de traitement des QueuedMessage  

143:     lazyWriter = MessageLazyWriter(  

144:         self.config.sections[’lazywriter’], \  

145:         self.message_queue, self.connectors, \  

146:         self.stopper )  

147:     lazyWriter.start()  

148:  

149:     try:  

150:       self.connect_broker()  

151:     except Exception as err:  

152:       self.logger.error(  

153:         ’connect_broker() error with %s’ % err)  

154:       raise  

155:  

156:     try:  

157:       self.mqtt.loop_forever()  

158:     except Exception as err:  

159:       self.logger.error(  

160:         ’Error while processing broker messages! %s’ % err )  

161:     except KeyboardInterrupt:  

162:       self.logger.info(  

163:         ’User abord with KeyboardInterrupt exception’ )  

164:     except SystemExit:  

165:       self.logger.info(  

166:         ’System exit with SystemExit exception!’ )  

167:  

168:     self.stopper.set()  

169:  

170:     self.logger.info( ’Waiting for LazyWriter thread...’)  

171:     lazyWriter.join()

Un dernier petit rappel sur la structure du fichier de configuration push-to-db.ini pour détailler la définition des classes de captures et des connecteurs. Ces informations seront utilisées pour instancier les objets adéquats à la volée.

[connector.sqlitedb]  

class=SqliteConnector  

db=/var/local/sqlite/pythonic.db  

 

[mqtt.capture.0]  

subscribe=maison/rez/#,maison/exterieur/#,maison/cave/#  

class=MqttTopicCapture  

storage=sqlitedb.topicmsg  

 

[mqtt.capture.1]  

subscribe=maison/exterieur/cabane/lux,maison/exterieur/jardin/hrel,maison/

exterieur/jardin/temp  

class=MqttTimeserieCapture  

storage=sqlitedb.ts_cab

La classe App expose les propriétés et méthodes suivantes :

Méthode build_connectors( self )

Cette méthode de App constitue le dictionnaire connectors en instanciant les différentes classes dérivées de BaseConnector mentionnées dans les sections [connector.x] du fichier de configuration.

Voici la section de code correspondant à build_connectors() :

17:   def build_connectors( self ):  

18:     self.connectors = {} # Réinitialiser le dict.  

19:     # collecter la liste des sections "connector.xxxx"  

20:     lst = self.config.search_section( "^connector\.\w+$" )  

21:     for section in lst:  

22:       connector_classname = self.config.get(  

23:          section, ’class’)  

24:       # reference vers la classe  

25:       connector_class = globals()[connector_classname]  

26:       # créer la classe et l’enregistrer  

27:       # sous son nom simple  

28:       self.connectors[  

29:          section.replace(’connector.’,’’)  

30:                      ] = \  

31:          connector_class( self.config.sections[section] )

Méthode build_sub_handlers(self)

Cette méthode constitue la liste sub_handler en instanciant les différentes classes issues de MqttBaseCapture mentionnées dans les différentes sections [mqtt.capture.x] du fichier de configuration.

Voici la section de code correspondant à build_sub_handlers() :

33:   def build_sub_handlers( self ):  

34:     self.sub_handlers = [] # Réinitialiser la liste  

35:     # collecter la liste des sections "mqtt.capture.x"  

36:     lst = self.config.search_section(  

37:          "^mqtt\.capture\.\d+$" )  

38:     for section in lst:  

39:       handler_classname = self.config.get( section, ’class’  

40:            )  

41:       # reference vers la classe  

42:       handler_class = globals()[handler_classname]  

43:       # Identifier instance du connecteur  

44:       connector_name = self.config.get(  

45:           section, ’storage’).split(’.’)[0]  

46:       if not(connector_name in self.connectors):  

47:         raise Exception(  

48:           ’No [connector.%s] defined for storage=%s (see  

49:                [%s])’%  

50:           (connector_name,  

51:               self.config.get( section, ’storage’),  

52:               section)  

53:         )  

54:       connector = self.connectors[connector_name]  

55:       self.sub_handlers.append(  

56:         # créer une instance de la classe  

57:         handler_class(  

58:            self.config.get( section, ’subscribe’ ),  

59:            self.config.get( section, ’storage’),  

60:            connector  

61:         )  

62:       )  

La méthode build_sub_handlers() est très similaire à build_connectors() sauf pour les exceptions suivantes :

1.

Les sections lues dans le fichier de configuration sont [mqtt.capture.x] et non [connector.x].

2.

Les classes instanciées sont des descendants de MqttBaseCapture et non des descendants de BaseConnector.

Sont reprises ci-dessous les lignes qui présentent de réelles différences par rapport à build_connectors(). La section [mqtt.capture.0] est utilisée comme référence.

Méthode _mqtt_on_message( self, client, userdata, message )

Cette méthode est appelée par le client MQTT à la réception de chaque message transmis par le broker (suite aux différentes souscriptions). Cette méthode identifie les sub_handlers concernés par le message et effectue la mise en queue pour le futur traitement à réaliser par MessageLazyWriter.

Voici la section de code correspondant à _mqtt_on_message() :

68:   def _mqtt_on_message( self, client, userdata, message ):  

69:     self.logger.info( "getting MQTT message..." )  

70:     self.logger.info( "  topic  : %s" % message.topic )  

71:     self.logger.info( "  message: %s" % message.payload )  

72:     self.logger.info( "  QoS    : %s" % message.qos )  

73:     try:  

74:       to_call = {} # sub handler à appeler  

75:       for sub_handler in self.sub_handlers:  

76:         if sub_handler.match_subscription( message.topic ):  

77:           # Identifier la destination de sauvegarde pour  

78:           #    eviter  

79:           # de sauvegarder plusieurs fois le message dans la  

80:           # même table  

81:           target_id = sub_handler.target_id()  

82:           if not( target_id in to_call ):  

83:             to_call[target_id] = sub_handler  

84:  

85:       for target_id, sub_handler in to_call.items():  

86:         to_queue = QueuedMessage(  

87:             receive_time=datetime.datetime.now(), \  

88:             topic=message.topic, payload=message.payload, \  

89:             qos=message.qos, sub_handler=sub_handler  )  

90:         self.message_queue.put( to_queue )  

91:  

92:     except Exception as err:  

93:       self.logger.error(  

94:           ’Exception while processing MQTT message’)  

95:       self.logger.error( "  topic: %s" % message.topic )  

96:       self.logger.error( "  message: %s" % message.payload )  

97:       self.logger.error( "  exception: %s" % err )

Méthode connect_broker( self )

Cette méthode connecte (ou reconnecte) l’application au broker MQTT à l’aide d’un client MQTT. La méthode effectue également toutes les souscriptions mentionnées dans les différentes sections [mqtt.capture.x].

Voici la section de code correspondant à connect_broker() :

99:   def connect_broker( self ):  

100:     if self.mqtt:  

101:       del( self.mqtt )  

102:       self.mqtt = None  

103:     self.mqtt_connected = False  

104:  

105:     self.mqtt = mqtt_client.Client(  

106:         client_id = ’push-to-db’ )  

107:     self.mqtt.on_connect = self._mqtt_on_connect  

108:     self.mqtt.on_message = self._mqtt_on_message  

109:     if not( self.config.get(  

110:               ’mqtt.broker’, ’username’, default = None  

111:             ) in (None, ’None’) ):  

112:       self.mqtt.username_pw_set(  

113:         username = self.config.get( ’mqtt.broker’,  

114:              ’username’),  

115:         password = self.config.get( ’mqtt.broker’,  

116:              ’password’)  

117:       )  

118:     self.mqtt.connect(  

119:         host = self.config.get( ’mqtt.broker’,  

120:              ’mqtt_broker’ ),  

121:         port = self.config.getint( ’mqtt.broker’,  

122:              ’mqtt_port’ ),  

123:         keepalive = self.config.getint( ’mqtt.broker’,  

124:              ’mqtt_keepalive’)  

125:     )  

126:  

127:     # effectue toutes les souscriptions nécessaires  

128:     sub_done = []  

129:     for sub_handler in self.sub_handlers:  

130:       for sub in sub_handler.sub_filters:  

131:         # Ne pas faire deux fois la même souscription  

132:         if not sub in sub_done:  

133:           self.logger.info( ’subscribing %s’ % sub )  

134:           self.mqtt.subscribe( sub )  

135:           sub_done.append( sub )

Méthode run( self )

Cette méthode prend en charge l’exécution de la partie applicative du script push-to-db.py.

Voici la section de code correspondant à run() :

137:   def run( self ):  

138:     self.logger.info( ’Running app’)  

139:     self.message_queue = Queue()  

140:     self.stopper = threading.Event()  

141:  

142:     # Thread de traitement des QueuedMessage  

143:     lazyWriter = MessageLazyWriter(  

144:         self.config.sections[’lazywriter’],  

145:         self.message_queue, self.connectors,  

146:         self.stopper )  

147:     lazyWriter.start()  

148:  

149:     try:  

150:       self.connect_broker()  

151:     except Exception as err:  

152:       self.logger.error(  

153:         ’connect_broker() error with %s’ % err)  

154:       raise  

155:  

156:     try:  

157:       self.mqtt.loop_forever()  

158:     except Exception as err:  

159:       self.logger.error(  

160:         ’Error while processing broker messages! %s’ % err )  

161:     except KeyboardInterrupt:  

162:       self.logger.info(  

163:         ’User abord with KeyboardInterrupt exception’ )  

164:     except SystemExit:  

165:       self.logger.info(  

166:         ’System exit with SystemExit exception!’ )  

167:  

168:     self.stopper.set()  

169:  

170:     self.logger.info( ’Waiting for LazyWriter thread...’)  

171:     lazyWriter.join()

La classe App et sa méthode run() sont utilisées en fin de script push-to-db.py pour démarrer celui-ci.

if __name__ == "__main__":  

       app = App()  

       app.run()

Configuration de push-to-db

Le script push-to-db.py utilise un fichier de configuration, un fichier de base de données et un fichier de log. Ces éléments sont stockés en suivant les recommandations FHS (Filesystem Hierarchy Standard) pour les distributions Linux et les autres documentations annexes concernant le système de fichier Linux.

Un script d’installation setup.sh est disponible dans le sous-répertoire /push-to-db/ du dépôt GitHub du projet. Ce script Shell, détaillé plus loin dans cette section, prend soin de configurer les accès, de placer les différents éléments au bon endroit, de créer la base de données, etc.

images/05RI09.pngimages/05RI09.png
 

Script d’installation pour push-to-db.py

Le système de fichiers Linux

Une abondante documentation existe sur le sujet. Les points ci-dessous représentent une source d’information de choix.

1. Les répertoires de stockage de push-to-db

Fichier de configuration

Le répertoire /etc contient les informations de configuration spécifiques au système. Le fichier de configuration push-to-db.ini est stocké dans le répertoire /etc/pythonic/.

Il est nécessaire d’être super utilisateur pour éditer le contenu du fichier avec la commande :

sudo nano /etc/pythonic/push-to-db.ini

Base de données

Le répertoire /var/local/sqlite est destiné aux bases de données SQLite, y compris pythonic.db qui stocke les messages réceptionnés par le broker.

Le répertoire /var contient des fichiers dont le contenu est susceptible de changer constamment. Le répertoire /var/local/ est utilisé pour stocker des données variables spécifiques au système de fichiers local. À moins de configurer les droits de façon appropriée, seul le super utilisateur peut altérer le contenu de ce répertoire.

Fichier journal

Le script utilise un logger Python configuré dans le fichier de configuration push-to-db.ini. Le fichier journal push-to-db.log est, par défaut, placé dans /var/log/pythonic/.

L’accès en écriture à ce répertoire en tant qu’utilisateur pi nécessite une configuration adéquate des droits d’accès.

Le script push-to-db

Dans une installation définitive, le script push-to-db.py trouverait sa place dans le répertoire /usr/bin.

Étant donné que cet élément est activement en cours de développement au moment de la rédaction de ces lignes, son emplacement est préservé dans le répertoire utilisateur du Raspberry Pi ( donc sous /home/pi/ ) où il est tout aussi facile de l’exécuter.

 

Le script push-to-db.py est accessible dans l’un des sous-répertoires de la-maison-pythonic résultant du clonage du projet GitHub. Par exemple :

/home/pi/la-maison-pythonic/python/push-to-db/push-to-db.py

2. Création des tables de push-to-db

Les tables sont créées à l’aide du script SQL createdb.sql détaillé ci-dessous, script également disponible sur le dépôt GitHub du projet (la-maison-pythonic/python/push-to-db/createdb.sql).

create table topicmsg (  

 id integer primary key,  

 topic text, 

 message text, 

 qos integer, 

 rectime integer, 

 tsname text  

); 

 

CREATE UNIQUE INDEX idx_topicmsg ON topicmsg(topic); 

 

create table ts_cab ( 

 id integer primary key, 

 topic text, 

 message text, 

 qos integer, 

 rectime integer 

); 

 

create table ts_salon ( 

 id integer primary key, 

 topic text, 

 message text, 

 qos integer, 

 rectime integer 

); 

 

create table ts_chauf ( 

 id integer primary key, 

 topic text, 

 message text, 

 qos integer, 

 rectime integer 

);

L’utilitaire sqlite3 est utilisé pour créer la base de données. Si SQLite 3 n’est pas encore disponible, il peut être installé avec la commande sudo apt-get install sqlite.

Par la suite, la base de données peut être créée à l’aide de l’instruction :

pi@pythonic:~ $ cat createdb.sql | sqlite3 pythonic.db

Avant d’être déplacée dans le répertoire /var/local/sqlite.

 

À noter que la création et l’accès à la base de données dans le répertoire /var/local/sqlite nécessite une configuration de droit particulier. Ce point est détaillé dans le script d’installation setup.sh (ci-dessous).

3. push-to-db.ini

Le fichier de configuration push-to-db.ini contient toutes les informations nécessaires au fonctionnement du script push-to-db.py.

01: [mqtt.broker]  

02: mqtt_broker=pythonic.local  

03: mqtt_port=1883  

04: mqtt_keepalive=45  

05: username=pusr103  

06: password=21052017  

07:  

08: [lazywriter]  

09: MaxQueueLatency=120  

10: MaxQueueSize=10  

11: PauseAfterProcess=2  

12:  

13: [connector.sqlitedb]  

14: class=SqliteConnector  

15: db=/var/local/sqlite/pythonic.db  

16:  

17: [mqtt.capture.0]  

18: subscribe=maison/rez/#,maison/exterieur/#,maison/cave/#  

19: class=MqttTopicCapture  

20: storage=sqlitedb.topicmsg  

21:  

22: [mqtt.capture.1]  

23: subscribe=maison/exterieur/cabane/lux,maison/exterieur/jardi  

24:     n/hrel,maison/exterieur/jardin/temp  

25: class=MqttTimeserieCapture  

26: storage=sqlitedb.ts_cab  

27:  

28: [mqtt.capture.2]  

29: subscribe=maison/rez/salon/temp,maison/rez/salon/pir  

30: class=MqttTimeserieCapture  

31: storage=sqlitedb.ts_salon  

32:  

33: [mqtt.capture.3]  

34: subscribe=maison/cave/chaufferie/etat,maison/cave/chaufferie  

35:     /temp-eau  

36: class=MqttTimeserieCapture  

37: storage=sqlitedb.ts_chauf  

38:  

39: [loggers]  

40: keys=root,connector,pmq  

41:  

42: [handlers]  

43: keys=console,logfile  

44:  

45: [formatters]  

46: keys=default  

47:  

48:  

49: [logger_root]  

50: level=NOTSET  

51: handlers=console,logfile  

52:  

53: [logger_connector]  

54: level=ERROR  

55: handlers=console,logfile  

56: qualname=connector  

57:  

58: [logger_pmq]  

59: level=ERROR  

60: handlers=console,logfile  

61: qualname=pmq  

62:  

63: [handler_console]  

64: class=StreamHandler  

65: level=NOTSET  

66: formatter=default  

67: args=(sys.stdout,)  

68:  

69: [handler_logfile]  

70: class=FileHandler  

71: level=NOTSET  

72: formatter=default  

73: args=(’/var/log/pythonic/push-to-db.log’, ’w’)  

74:  

75: [formatter_default]  

76: format=%(asctime)s %(levelname)s %(message)s  

77: datefmt=

La section mqtt.broker

Cette section (lignes 01 à 06) contient les informations permettant de connecter le client MQTT sur le broker.

La section lazywriter

Cette section (lignes 08 à 11) contient les informations permettant de configurer le comportement du thread MessageLazyWriter.

La section connector.x

Les sections connecteurs permettent de déclarer les connecteurs (via une classe à instancier) et les paramètres de configuration à passer à ladite instance.

Cela permet de connecter le script push-to-db.py sur plusieurs destinations distinctes (plusieurs bases de données SQLite ou tout autre connecteur disponible dans l’application) pour y sauver des messages.

Le texte situé après le point dans le nom de section (ex. : sqlitedb pour la section [connector.sqlitedb] ) devient un identifiant réutilisé plus loin dans la configuration.

Paramètre

Description

Classe = SqliteConnector

db

Requis. Chemin complet permettant d’accéder à une base de données Sqlite3. Par exemple : /var/local/sqlite/pythonic.db.

La section mqtt.capture.x
 

Les sections de capture permettent de définir les messages à capturer et le connecteur à utiliser pour stocker les messages capturés.

Les sections mqtt.capture.x définissent également la classe de capture à utiliser (la classe dérivée de MqttBaseCapture à instancier). La section contient également les divers paramètres à passer à la classe durant la création de celle-ci.

Le numéro situé après le point dans le nom de section (ex. : 0 pour la section [mqtt.capture.0] ) est un identifiant unique de la section.

Les sections loggers, handlers, formatters, logger_x, handler_x

Toutes ces sections concernent la configuration du logger Python.

Ce point est abordé plus en détail dans la section Logger Python de ce chapitre.

4. Le script d’installation de push-to-db

Le script d’installation setup.sh disponible dans le sous-répertoire /push-to-db/ du dépôt GitHub du projet permet de procéder rapidement à l’installation de la base de données et de tous les éléments de la configuration.

Le script prend également en compte quelques éléments de sécurité détaillés dans l’explication ci-dessous.

Ce script peut être exécuté à l’aide des commandes suivantes :

pi@pythonic:~ $ cd la-maison-pythonic/python/push-to-db/  

pi@pythonic:~/la-maison-pythonic/python/push-to-db $ ./setup.sh

Voici les détails du script d’installation :

01: #!/bin/sh -  

02: echo "Install SQLite3"  

03: sudo apt-get install sqlite  

04:  

05: echo "Create storage path /var/local/sqlite"  

06: sudo mkdir /var/local/sqlite  

07:  

08: echo "Add user ’pi’ to group ’staff’"  

09: sudo usermod -a -G staff pi  

10: echo "Set ’staff’ as group for ’sqlite’ folder"  

11: sudo chgrp staff /var/local/sqlite  

12: echo "Give right to group ’staff’ on ’sqlite’ folder"  

13: sudo chmod g+rwx /var/local/sqlite  

14:  

15: echo "Install push-to-db.ini to /etc/pythonic/"  

16: sudo mkdir /etc/pythonic  

17: sudo cp inifile.sample /etc/pythonic/push-to-db.ini  

18:  

19: echo "Install push-to-db.log in /var/log/pythonic/"  

20: sudo mkdir /var/log/pythonic  

21: sudo chgrp staff /var/log/pythonic  

22: sudo chmod g+rw /var/log/pythonic  

23:  

24: touch /var/log/pythonic/push-to-db.log  

25: sudo chmod +664 /var/log/pythonic/push-to-db.log  

26: sudo chown root /var/log/pythonic/push-to-db.log  

27:  

28: echo "Creating pythonic.db in var/local/sqlite/"  

29: cat createdb.sql | sqlite3 /var/local/sqlite/pythonic.db  

30:  

31: echo "Installing python libraries"  

32: sudo pip install paho-mqtt  

33:  

34: echo "Done!"

 

Le répertoire /var/local est détenu par l’utilisateur root et accessible aux membres du groupe staff. Ce point peut être vérifié avec la commande ls -l /var/local. Le script fera en sorte de suivre la même règle pour le répertoire /var/local/pythonic tout en permettant à l’utilisateur pi de pouvoir y accéder. Cela permettra au script push-to-db.py, d’être exécuté en ligne de commande et d’accéder librement au fichier de base de données.

 

La commande ls -l /etc/pythonic révèle que le fichier ini dispose de la configuration d’accès suivante :

-rw-r--r-- 1 root root 2181 Apr 16 22:07 push-to-db.ini

Ce qui signifie que le fichier ini est accessible en lecture par tous (le propriétaire, le groupe et les autres) mais uniquement modifiable par le propriétaire (un sudo sera donc de rigueur pour adapter le contenu).

 

La commande touch de la ligne 24 crée le fichier sans utiliser sudo. Par conséquent, le propriétaire du fichier est pi et le groupe est également pi pour ce même fichier. Le changement de mode en ligne 25 permet au propriétaire et au groupe d’écrire dans le fichier. Pour finir le changement de propriétaire en root permettra au système de modifier le fichier (ce qui sera le cas sous systemd) tandis que le groupe pi permettra toujours à l’utilisateur pi de modifier également le fichier (ce qui est le cas lors de l’utilisation en ligne de commande).

Logger Python

Python dispose d’un module logging offrant de très nombreuses fonctionnalités facilitant les opérations de journalisation.

Le script push-to-db.py utilise un logger Python associé à une configuration présente dans le fichier push-to-db.ini.

La documentation Python 2.7 offre une excellente référence en anglais sous le nom « Logging Cookbook » (https://docs.python.org/2/howto/logging-cookbook.html).

1. Logger et fichier de configuration

Il est possible d’initialiser un logger dans le script principal depuis un fichier de configuration.

Voici une section de code extraite de push-to-db.py où le fichier ini est utilisé pour configurer le logger. En créant une instance du logger en début de script, cette instance sera active durant tout le temps de fonctionnement du script. L’instance pourra être facilement récupérée à l’aide de la fonction getLogger( nom_du_logger ) du module logging.

import logging, logging.config  

INIFILE = "/etc/pythonic/push-to-db.ini"  

logger = logging.config.fileConfig( INIFILE )

2. Configuration du logger

Le fichier de configuration du logger utilise une structure inifile pour définir cette configuration qui peut donc cohabiter avec les autres paramètres du script push-to-db.py.

Parmi ces paramètres, le plus important est la section définissant les loggers disponibles pour l’application :

[loggers]  

keys=root,connector,pmq

Les objets root, connectors et pmq sont donc accessibles depuis le script Python. À noter que les noms utilisables dans le script Python (le qualname) sont précisés plus loin dans la configuration.

Le projet prévoit trois loggers :

La configuration reprend également la définition des handlers. Ces handlers permettent d’envoyer les messages vers un fichier, un log circulaire, la console, un stream, un serveur de journalisation, etc.

La section [handlers] contient une liste des handlers disponibles :

[handlers]  

keys=console,logfile

Dans le cas présent, la configuration prévoit deux handlers : console et logfile (fichier journal).

Chaque handler doit ensuite avoir une section [handler_<nom_du_hander>] configurant précisément celui-ci.

Le handler console renvoie tous les messages vers la console à l’aide de la classe StreamHandler.

[handler_console]  

class=StreamHandler  

level=NOTSET  

formatter=default  

args=(sys.stdout,)

Le niveau NOTSET indique que tous les messages (DEBUG, INFO, WARNING, ERROR, CRITICAL) seront capturés par ce handler.

Le handler logfile renvoie les messages vers un fichier à l’aide de la classe FileHandler. Comme pour le handler console, tous les messages seront capturés (level=NOTSET).

[handler_logfile]  

class=FileHandler  

level=NOTSET  

formatter=default  

args=(’/var/log/pythonic/push-to-db.log’, ’w’)

Les handlers utilisent un formatter pour mettre en forme le message envoyé vers le handler. À noter que les formatters disponibles doivent également être énumérés dans une section [formatters].

[formatters]  

keys=default  

[formatter_default]  

format=%(asctime)s %(levelname)s %(message)s  

datefmt=

Vient enfin la configuration des différents loggers dans des sections [logger_<nom_du_logger>]. Il y aura donc trois sections puisqu’il y a trois loggers : root, connector, pmq.

[logger_root]  

level=NOTSET  

handlers=console,logfile  

 

[logger_connector]  

level=ERROR  

handlers=console,logfile  

qualname=connector  

 

[logger_pmq]  

level=ERROR  

handlers=console,logfile  

qualname=pmq

Ces sections définissent pour chaque logger :

3. Utilisation du logger

Comme précisé ci-dessus, le logger dispose de trois configurations de base qui sont : root, connector, pmq (process message queue).

Ces configurations dans le fichier d’initialisation doivent être lues par le script Python. Cela se fait à l’aide de fileConfig comme indiqué ci-dessous.

import logging, logging.config 

INIFILE = "/etc/pythonic/push-to-db.ini"  

# A exécuter aussi vite que possible  

logger = logging.config.fileConfig( INIFILE )

Par la suite, il est très facile d’obtenir une référence vers un logger en utilisant la fonction getLogger(nom_du_logger) du module logging.

monLog = logging.getLogger(’root’)

La référence vers monLog offre différentes méthodes pour envoyer des messages vers le logger correspondant.

monLog.debug(’ --list of sub_filters’ ) 

monLog.info( ’LazyWriter queue size reached’ ) 

monLog.error( ’process_message_queue encounter an error’ ) 

# voir aussi les méthodes :  

# * warning() 

# * critical() 

# * exception() qui fait un log en debug avec l’info d’exception.

En fonction de la configuration du niveau de logging (level=), ces messages sont ignorés ou pas. La configuration permet également d’envoyer ces messages vers plusieurs destinations (handlers=) comme la console, des fichiers de logs ou des serveurs TCP/IP de log.

Exécution du script push-to-db

Le script Python est configuré de telle sorte qu’il peut être utilisé en ligne de commande ou contrôlé par l’intermédiaire d’un service systemd.

L’exécution en ligne de commande procure l’avantage de produire un affichage des journaux directement sur la console, ce qui facilite l’identification immédiate des erreurs (voir la section Configuration du logger ci-dessus).

 

Il est essentiel que les paramètres et l’emplacement des fichiers soient configurés comme recommandé dans la section de configuration. Cette section propose d’ailleurs un script bash setup.sh pour faciliter l’installation.

Le script push-to-db.py est présent dans le sous-répertoire /push-to-db/ du dépôt GitHub du projet. Il est également disponible dans le répertoire utilisateur une fois le dépôt GitHub cloné. 

Ce script peut être exécuté avec python 2.7 à l’aide des commandes suivantes :

pi@pythonic:~ $ cd la-maison-pythonic/python/push-to-db/  

pi@pythonic:~/la-maison-pythonic/python/push-to-db $ python push-to-db.py

Voici les détails des messages affichés durant le fonctionnement du script en mode console.

pi@pythonic:~/la-maison-pythonic/python/push-to-db $ python push-to-db.py  

2018-06-25 12:12:16,217 INFO Initializing app  

2018-06-25 12:12:16,231 INFO Running app  

2018-06-25 12:12:16,232 DEBUG LazyWriter thread started  

2018-06-25 12:12:16,340 INFO subscribing maison/rez/salon/temp  

2018-06-25 12:12:16,342 INFO subscribing maison/rez/salon/pir  

2018-06-25 12:12:16,345 INFO subscribing maison/rez/#  

2018-06-25 12:12:16,348 INFO subscribing maison/exterieur/#  

2018-06-25 12:12:16,350 INFO subscribing maison/cave/#  

2018-06-25 12:12:16,351 INFO subscribing maison/exterieur/cabane/lux  

2018-06-25 12:12:16,352 INFO subscribing maison/exterieur/jardin/hrel  

2018-06-25 12:12:16,354 INFO subscribing maison/exterieur/jardin/temp  

2018-06-25 12:12:16,355 INFO subscribing maison/cave/chaufferie/etat  

2018-06-25 12:12:16,357 INFO subscribing maison/cave/chaufferie/temp-eau  

2018-06-25 12:12:16,360 INFO mqtt connect return code: 0  

2018-06-25 12:13:17,957 INFO getting MQTT message...  

2018-06-25 12:13:17,958 INFO   topic  : maison/cave/chaufferie/temp-eau  

2018-06-25 12:13:17,959 INFO   message: 22.69  

2018-06-25 12:13:17,959 INFO   QoS    : 0  

2018-06-25 12:13:17,960 DEBUG LazyWriter latency_start set to now  

2018-06-25 12:15:18,774 INFO getting MQTT message...  

2018-06-25 12:15:18,780 INFO   topic  : maison/cave/chaufferie/temp-eau  

2018-06-25 12:15:18,781 INFO   message: 22.63  

2018-06-25 12:15:18,784 INFO   QoS    : 0  

2018-06-25 12:15:18,961 INFO LazyWriter latency 120 sec reached ->

Process_message_queue.

Le programme peut être interrompu à tout moment en pressant la combinaison de touches [Ctrl] C dans le terminal.

Service systemd pour push-to-db

Pour exécuter une commande ou un programme au démarrage du Raspberry Pi, il est nécessaire d’ajouter un « service ». Ce service pourra alors être démarré/arrêté ou activé/désactivé par l’intermédiaire d’une ligne de commande.

Depuis Raspbian Jessie, le démon d’initialisation systemd remplace l’ancien système d’initialisation Système V à base de script. Ainsi donc, pour démarrer automatiquement un programme ou un script, il faut passer par systemd.

Cette section propose de configurer un service push-to-db.service afin de démarrer automatiquement le script push-to-db.py au démarrage du système.

 

Il est essentiel que les paramètres et l’emplacement des fichiers soient configurés comme recommandé dans la section de configuration. Cette section propose d’ailleurs un script bash setup.sh pour faciliter l’installation.

1. Quand démarrer le service ?

La séquence d’initialisation du système d’exploitation est relativement longue, même sous systemd. 

D’une façon générale, il est préférable de démarrer les scripts Python :

2. Créer le fichier Unit

Le fichier Unit sert à stocker la configuration du service et indique à systemd ce qui doit être exécuté et quand cela doit être exécuté.

Utiliser l’éditeur de votre choix pour créer le fichier Unit nommée push-to-db.service. Dans le cas présent, l’éditeur de texte nano est utilisé. La commande est assortie d’un sudo car le répertoire de destination n’est pas libre d’accès.

sudo nano /lib/systemd/system/push-to-db.service

Le fichier push-to-db.service doit contenir les informations suivantes :

[Unit]  

Description=Stockage DB MQTT pour  la-maison-pythonic  

After=multi-user.target  

Wants=mosquitto.target  

 

[Service]  

Type=idle  

ExecStart=/usr/bin/python /home/pi/la-maison-pythonic/python/push-to-db/

push-to-db.py  

 

[Install]  

WantedBy=multi-user.target

Le fichier unit doit avoir la permission 644 avant de pouvoir l’intégrer à systemd.

sudo chmod 644 /lib/systemd/system/push-to-db.service

Les différents paramètres du fichier Unit sont les suivants :

 

À noter qu’il est possible d’indiquer les dépendances sous la forme After=multi-user.target mosquitto.target permettant d’atteindre le même résultat, mais sans contrainte.

3. Configurer, démarrer, contrôler

Maintenant que le fichier Unit est prêt, la commande suivante indique à systemd qu’il faut le démarrer durant la séquence de démarrage.

sudo systemctl deamon-reload 

sudo systemctl enable push-to-db.service

 

À partir de maintenant, le service push-to-db démarrera automatiquement à chaque démarrage du Raspberry Pi. Le service peut être désactivé avec la commande sudo systemctl disable push-to-db.service.

Le service peut être démarré directement avec la commande ci-dessous :

sudo systemctl start push-to-db.service

Enfin, il est possible de contrôler l’état du service à tout moment avec la commande :

sudo systemctl status push-to-db.service

Ce qui fournit de nombreuses informations sur le service comme son statut, l’identification du processus, la commande utilisée et les derniers messages envoyés à la console.

pi@pythonic:~ $ sudo systemctl status push-to-db.service  

. push-to-db.service - MQTT database storage service for la-maison-pythonic project 

  Loaded: loaded (/lib/systemd/system/push-to-db.service; enabled)  

  Active: active (running) since Mon 2018-06-25 16:07:25 UTC; 1min 2s ago  

Main PID: 3719 (python)  

  CGroup: /system.slice/push-to-db.service  

          └─3719 /usr/bin/python /home/pi/la-maison-pythonic/python/push-to-... 

 

Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,853 INFO subscri...ir 

Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,855 INFO subscri.../# 

Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,857 INFO subscri.../# 

Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,858 INFO subscri.../# 

Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,860 INFO subscri...ux 

Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,861 INFO subscri...el 

Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,862 INFO subscri...mp 

Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,864 INFO subscri...at 

Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,866 INFO subscri...au 

Jun 25 16:07:25 pythonic python[3719]: 2018-06-25 16:07:25,868 INFO mqtt co... 0 

Hint: Some lines were ellipsized, use -l to show in full.

4. Documentation sur systemd

Internet regorge d’informations sur systemd, mais les liens suivants représentent d’excellentes sources d’information :

Améliorations

Sous sa forme actuelle, le script push-to-db.py offre un service relativement élémentaire qui peut recevoir de nombreuses améliorations.

Par exemple :

Présentation de Flask

Flask est un micro framework de développement web écrit en Python.

images/06RI02.pngimages/06RI02.png
 

Logo du projet Flask

Allant à contre-pied d’autres solutions de développement web, Flask est livré avec le strict minimum, à savoir :

images/06RI01.pngimages/06RI01.png
 

Structure du micro framework Flask

Disposer d’un micro framework implique donc l’absence de certains éléments out-of-the-box tels que :

Cela n’est cependant pas un frein, car la grande flexibilité du micro framework permet l’adjonction d’une pléthore d’extensions Flask couvrant ces manques apparents, extensions dont certaines sont décrites plus loin dans ce chapitre.

1. Pourquoi Flask ?

Pour commencer, parce qu’il utilise Python, ce qui reste dans les objectifs du présent ouvrage.

Flask n’est cependant pas le seul framework de développement web Python disponible. Il existe d’autres alternatives comme Bottle (http://bottlepy.org), Django (https://www.djangoproject.com/) ou CherryPy (https://cherrypy.org).

Django est une perle dans le domaine du développement web Python. Il dispose de nombreuses fonctionnalités et rien n’y manque pour réaliser un développement de niveau professionnel. La contrepartie, c’est une solution plus lourde à mettre en œuvre avec une courbe d’apprentissage plus longue. Django sera plutôt réservé à des projets d’envergure.

Flask et Bottle quant à eux permettent de produire les premières pages en moins de 5 minutes (installation comprise). Ces solutions plus légères en termes de ressources conviendront mieux à notre projet sur Raspberry Pi.

Ce qui a guidé le choix entre Bottle et Flask est la popularité, la documentation et les extensions de Flask. L’expérience ayant par ailleurs démontré que ce dernier permet de réaliser des développements de qualité professionnelle.

2. La flexibilité de Flask

Ce qui rend Flask si populaire et attractif, c’est sa grande flexibilité. Dès le début du développement, le micro framework a intégré de nombreux mécanismes facilitant le développement d’extensions.

Ainsi, Flask intègre des mécanismes comme les hooks, extend (développement d’extension), Signals (notification par signaux), dérivation de la classe Flask et middleware (introduction de corrections entre le serveur HTTP et l’application Flask).

Un document est disponible en ligne pour découvrir ces différents mécanismes : http://flask.pocoo.org/docs/1.0/becomingbig/

3. Les nombreuses extensions Flask

Ces dernières années ont vu l’apparition de très nombreuses extensions permettant d’ajouter, au cas par cas, les fonctionnalités nécessaires sur le micro framework.

Un catalogue des extensions est disponible sur le lien suivant : http://flask.pocoo.org/extensions/

Les extensions sont hébergées sur des pages PyPI (Python Package Index), ce qui permet de les installer avec l’utilitaire pip comme n’importe quel autre paquet Python.

Le catalogue d’extensions est une liste modérée d’extensions Flask suivant les recommandations du guide des extensions Flask. De nombreuses ressources sont également disponibles sur Internet.

La liste suivante reprend les extensions les plus intéressantes. Savoir quelles extensions existent permet d’éviter de nombreux efforts et des recherches inutiles.

4. Flask plus en détails

Le schéma suivant présente le fonctionnement général du micro framework Flask et les différents éléments intervenant dans la chaîne de production du contenu.

images/06RI03.pngimages/06RI03.png
 

Fonctionnement détaillé du micro framework Flask

Voici une description des différents éléments composant le mini framework Flask :

a. Werkzeug

Werkzeug est un serveur web WSGI écrit en Python.

Il reçoit les requêtes HTTP et fait le nécessaire pour décoder les demandes et envoyer les réponses en retour. Werkzeug s’occupe des problématiques de gestion des sockets et connexions, traitement des tâches en parallèles, prise en charge des aspects sécuritaires (au niveau HTTP), etc.

Werkzeug est également un serveur compatible WSGI. Il est donc capable de transmettre la requête à l’application en suivant le protocole WSGI puis de récupérer la réponse et de la transmettre au client via HTTP.

Bien que ce projet ait débuté comme une simple boîte à outils WSGI, Werkzeug est aujourd’hui l’un des modules WSGI les plus puissants existant sur le marché.

Werkzeug inclut :

Dans le cadre d’une solution Flask, Werkzeug :

b. WSGI

WSGI (Web Server Gateway Interface) est une norme et un protocole de communication qui définissent comment un serveur web peut interagir avec une application Python pour envoyer des requêtes et recevoir des réponses.

WSGI est une norme pour serveur web comme l’est FastCGI, SCGI, ou WebSocket destinée au langage Python.

Un serveur web supportant WSGI doit être capable de transformer une requête HTTP en objet Python en suivant la norme WSGI. Il doit également être capable de recevoir la réponse sous forme d’objet Python pour renvoyer celle-ci vers le client web.

L’application Python (comme Flask) doit donc être capable de supporter les spécificités du protocole WSGI pour renvoyer des réponses par l’intermédiaire du serveur web via WSGI. L’application doit pouvoir recevoir une requête HTTP sous forme d’un objet Python (à la norme WSGI) et renvoyer la réponse avec un objet Python tel que défini dans la norme.

Tous les serveurs compatibles WSGI le sont avec toutes les applications compatibles WSGI. La norme WSGI assure que ces différents éléments sont interchangeables.  

Avantages de WSGI

Inconvénients du WSGI

Les serveurs web compatibles WSGI

Voici une liste non exhaustive de serveurs web supportant WSGI :

Une liste complète est disponible sur https://wsgi.readthedocs.io/en/latest/.

c. Application Flask

L’application utilisant le framework Flask traite les requêtes entrantes (communiquées par le serveur via WSGI) et produit les réponses correspondantes.

L’application Flask contient des « routes » permettant de réceptionner et de traiter les requêtes des clients pour produire les réponses correspondantes.

Pour réaliser cette tâche, l’application peut utiliser des connexions vers des bases de données afin d’obtenir les éléments nécessaires au rendu de la page. La connexion sur une base de données peut être établie soit en utilisant les modules Python disponibles, soit en utilisant des extensions Flask comme Flask-SQLAlchemy (ORM) ou Flask-CouchDB.

De même, l’application Flask peut produire directement du contenu HTML, CSV, JSON, XML, texte et autre, mais peut également s’appuyer sur le moteur de template de Jinja.

d. Jinja

Jinja est un puissant moteur de template permettant de formater et d’inclure les données produites ou collectées par l’application dans les documents. Bien que Jinja est principalement utilisé pour produire du contenu HTML, il peut également produire du contenu XML, LaTeX, CSV, etc.

Les documents HTML contiennent des balises spéciales interprétées et remplacées par le moteur de template afin de produire le document définitif.

Jinja supporte le traitement de structures de contrôle (test, boucle, assignation, comparaison, etc.), de filtres, d’expressions que l’on retrouve dans de nombreux moteurs de templates auquel viennent se joindre de puissantes fonctionnalités telles que l’héritage, les extensions, l’inclusion de template et de « blocks ».

e. Base de données

Par défaut, le micro framework de Flask n’inclut pas de support spécifique d’une base de données. Le développeur est donc libre de choisir le support de moteur de base de données de son choix, pour autant que celui-ci soit disponible dans l’environnement Python.

Il est également possible de faire appel à un ORM (Object Relationnal Mapper) tel que SQLAlchemy, ce qui en plus des fonctionnalités ORM ouvre la voie vers de nombreuses bases de données telles que Firebird, Microsoft SQL Server, MySQL, Oracle, PostgreSQL, SQLite, Sybase.

Les extensions Flask apportent également le support d’autres bases de données telles que CouchDB, FluidDB, MongoDB, ZODB.

Pour finir, Python supporte différentes bases de données via la DB-API (Database API) et celles-ci peuvent, bien entendu, être utilisées dans une application Flask. C’est le cas du présent projet avec une base de données SQLite.

 

ORM signifie Object-Relational Mapping. Il s’agit d’une technique de programmation qui permet d’accéder à une ou plusieurs bases de données relationnelles sous forme d’objets et de collection d’objets. Allant bien au-delà des concepts de tables, lignes et colonnes, un ORM permet de créer une structure de données objet offrant des méthodes et des propriétés avancées pouvant être vues comme une « base de données virtuelle » capable de joindre et manipuler des sources de données a priori incompatibles entre elles. Un ORM est donc un middleware entre la base de données et l’application.

5. Documentations

L’une des grandes forces de Flask est la qualité de sa documentation. Cette documentation est disponible sur les liens suivants :

Anatomie d’un projet Flask

L’élément le plus important d’un projet Flask est la structure des répertoires utilisée pour stocker les différents éléments du projet. Les quelques premiers fichiers qui y sont créés sont également cruciaux.

Si la structure n’est pas scrupuleusement respectée, rien n’y fera, le projet ne produira pas de résultats.

Voici la structure de base du projet Flask pour le projet « mon-projet » avec quelques éléments complémentaires pour illustrer la mise en place.

mon-projet/  

└── app  

   ├── __init__.py  

   ├── static  

   │   ├── website.css  

   │   ├── datagrid.css  

   │   ├── ico-new.png  

   │   ├── ico-top.png  

   │   └── logo.png  

   ├── templates  

   │   ├── index.html  

   │   ├── base.html  

   │   └── entries.html  

   ├── config.py  

   ├── views.py  

   └── models.py

Pour commencer, le projet est stocké dans son propre répertoire nommé « mon-projet », ce qui permettra de centraliser de nombreuses ressources comme la documentation, des spécifications, les autres scripts Python, l’environnement virtuel Python (virtualenv) ou les ressources indirectement liées au projet à développer.

Les éléments du développement Flask prennent tous place dans un sous-répertoire APP.

 

Un environnement virtuel Python (VirtualEnv) permet de disposer d’une configuration spécifique de Python pour un projet donné. Cela permet d’installer des modules et des bibliothèques Python pour un projet sans altérer, polluer ou détériorer la configuration générale de Python. Par convention, l’environnement virtuel est habituellement stocké dans le sous-répertoire venv du répertoire projet (donc mon-projet/venv/). Il est toujours intéressant de mentionner l’existence des environnements virtuels Python même si cela sort du cadre de l’ouvrage.

Installation et prise en main

L’installation de Flask sur Raspberry Pi se fait à l’aide de l’utilitaire pip (Python Install Package) :

sudo pip install Flask

Puis, le script suivant peut être saisi dans le fichier flask-minimal.py à l’aide de la commande nano flask-minimal.py. Une copie de ce fichier est disponible dans le dépôt GitHub du projet dans le sous-répertoire python/divers/.

01: # coding: utf8  

02: # Importer la bibliothèque Flask  

03: from flask import Flask  

04: 

05: # Initialisze l’application Flask  

06: app = Flask( __name__ )  

07:  

08: # Définir une route pour capturer la requête  

09: # et produire la réponse avec la fonction  

10: # dit_bonjour()  

11: @app.route(’/’)  

12: def dit_bonjour():  

13:   return ’Salut tout le monde!’  

14: 

15: # Démarrer l’application sur le port 8085  

16: app.run( debug=True, port=8085, host=’0.0.0.0’)

Les commentaires du code sont explicites, certaines lignes méritent cependant quelques explications complémentaires.

Ligne 11 : utilisation du décorateur app.route pour définir une route associée à l’URL « / » (racine du site) avec la fonction dit_bonjour().

Ligne 13 : retourne une chaîne de caractères comme contenu de la réponse (sans aucun formatage HTML).

Ligne 16 : démarrage de l’application et activation du serveur web en mode de débogage. Le paramètre host=’0.0.0.0’ permet au serveur d’accepter des connexions externes et donc de tester Flask depuis une autre machine du réseau.

 

Le paramètre host=’0.0.0.0’ concerne plutôt une situation de mise en production. Lorsqu’il est omis, les seules connexions autorisées sont celles depuis l’hôte local (donc localhost:8085 ou 127.0.0.1:8085). De même, il est vivement conseillé de désactiver le débogueur (debug=False) lors d’une mise en production.

 

Flask permet de définir le port (ici 8085) sur lequel le serveur web sera actif. Les requêtes du navigateur sont adressées au port 80, mais il est probable que le port 80 du futur serveur web soit déjà occupé par un processus ou fortement protégé par un serveur web. C’est pour cette raison que le serveur web de l’application utilise un port différent (8085 ou, plus commun, le port 5000).

Une fois le script prêt, le serveur web est lancé avec la commande python python-minimal.py. Ce qui produit le résultat suivant lorsque la page racine « / » est demandée depuis un navigateur internet. Le programme Python peut être interrompu à tout moment avec la combinaison de touches [Ctrl] C.

Cet exemple utilise volontairement le port 8085 au lieu du port 5000 largement préféré dans les applications Flask. En modifiant le port utilisé, il est possible de faire cohabiter plusieurs applications Flask distinctes sur un seul et même hôte (chaque application écoutant les requêtes entrant sur un port différent).

pi@pythonic:~ $ python flask-minimal.py  

* Running on http://0.0.0.0:8085/  

* Restarting with reloader  

192.168.1.22 - - [05/Jul/2018 15:11:09] "GET / HTTP/1.1" 200 -  

192.168.1.22 - - [05/Jul/2018 15:11:10] "GET /favicon.ico 

HTTP/1.1" 404 -  

192.168.1.22 - - [05/Jul/2018 15:11:10] "GET /favicon.ico HTTP/1.1" 404 -

images/06RI04.pngimages/06RI04.png
 

Obtention de la page racine depuis un navigateur sur le réseau local

 

Si le navigateur ne peut pas résoudre le nom de l’hôte sur le réseau local (pythonic.local), alors il sera nécessaire d’utiliser l’adresse IP assignée au Raspberry Pi (192.168.1.210 dans le présent cas).

1. L’utilitaire flask

Le micro framework inclut également un utilitaire en ligne de commande nommé flask. Ce dernier prend en charge l’exécution de l’application Flask (ce qui correspond à la ligne 16 qui doit alors être omise).

01: # coding: utf8  

02: # Importer la bibliothèque Flask  

03: from flask import Flask  

04: 

05: # Initialisze l’application Flask  

06: app = Flask( __name__ )  

07:  

08: # Définir une route pour capturer la requête  

09: # et produire la réponse avec la fonction  

10: # dit_bonjour()  

11: @app.route(’/’)  

12: def dit_bonjour():  

13:   return ’Salut tout le monde!’  

14: 

15: # Demarrage assuré par utilitaire flask  

16: # COMMENT !!! app.run( debug=True, port=8085, host=’0.0.0.0’)

L’utilitaire flask utilise les variables d’environnements FLASK_APP et FLASK_DEBUG.

Il s’utilise comme suit :

export FLASK_APP=/home/pi/flask-minimal.py 

export FLASK_DEBUG=1 

flask run

Ce qui produit l’affichage suivant à la mise en route de l’application :

pi@pythonic ~ $ flask run  

* Serving Flask app "flask-minimal"  

* Forcing debug mode on  

* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)  

* Restarting with stat  

* Debugger is active!  

* Debugger PIN: 252-768-511

Si cette approche est intéressante, elle manque cruellement de flexibilité, car le port est fixé et l’hôte restreint aux connexions locales (problématique pour un développement sur Raspberry Pi puisque le navigateur de test est généralement sur une autre machine du réseau). Le port est fixé à 5000 (valeur par défaut pour Flask).

2. Prise en main avancée

Dans la suite de ce chapitre, il sera parfois nécessaire d’utiliser des ressources plus élaborées. Il sera par conséquent nécessaire de faire appel à la structure des répertoires présentés ci-avant dans le chapitre (cf. Anatomie d’un projet Flask dans ce chapitre).

Cette section reprend l’exemple « Salut tout le monde ! » en suivant scrupuleusement la hiérarchie des répertoires et en produisant un rendu HTML rudimentaire.

Par ailleurs, un petit script Python nommé runapp.py est utilisé pour remplacer l’utilitaire flask. Cela permet de contrôler plus finement le démarrage de l’application et, entre autres, d’autoriser le mode de débogage pour les connexions externes.

Les fichiers et les répertoires sont disponibles dans le dépôt GitHub du projet sous le répertoire python/flask-demos/minimal-app/.

Voici la liste des fichiers et répertoires utilisés :

minimal-app  

├── app  

│   ├── static  

│   ├── templates  

│   ├── __init__.py  

│   ├── config.py  

│   ├── models.py  

│   └── views.py  

└── runapp.py

Ne disposant pas de ressources particulières, les répertoires static et templates sont actuellement vides.

Le fichier config.py contient une constante de configuration arbitrairement nommée PARAMS. 

# coding: utf8  

 

PARAMS = { ’nom’: ’Casimir’, ’age’ : 5 }

Vient ensuite le fichier views.py qui associe la fonction dis_bonjour() avec l’URI « / ».

Cette fois, la fonction retourne un contenu HTML plus étoffé.

# coding: utf8  

from app import app  

 

# importer les autres éléments déclarés  

# dans /app/__init__py selon les besoins  

#  

# from app import db, babel  

 

# importer les modèles pour accéder  

# aux données  

#  

from app.models import *  

 

@app.route(’/’)  

def dit_bonjour():  

  # Récupération de PARAMS (config.py)  

  p = app.config[’PARAMS’]  

  return """<!doctype html>  

<html>  

<head>  

 <title>Titre de la page</title>  

</head>  

<body>  

 <h1>Dis bonjour!</h1><br />  

 Bonjour tout le monde, je suis %s et j’ai %s ans!  

</body>  

</html>""" % (p[’nom’],p[’age’])

Le début du script views.py contient de nombreuses lignes en commentaire, ces dernières concernent l’inclusion d’éléments relatifs à des projets plus importants comme l’objet db pour accéder à une session de base de données, babel pour les traductions, etc.

L’inclusion du fichier models.py est volontaire même s’il est actuellement vide. Ce fichier est destiné à contenir des fonctions et des méthodes permettant d’accéder à des données dès lors qu’il y a une base de données impliquée dans le projet.

Vient ensuite la prise en charge de la requête « / » avec le décorateur app.route(’/’) et la fonction dit_bonjour(). Notez la récupération de la structure PARAMS (telle que définie dans config.py) avec p = app.config[’PARAMS’].

Remarquez que app.config est un simple dictionnaire Python.

Le fichier __init__.py

Le répertoire app étant constitué comme un package Python, ce dernier doit contenir un fichier d’initialisation intitulé __init__.py. Dans le cadre d’une application Flask, ce fichier est très important puisqu’il initialise tous les éléments clés de l’application (objet application, session de base de données, chargement de la configuration, mise en place des routes).

01: # coding: utf8  

02: # Importer la bibliothèque Flask  

03: from flask import Flask  

04: from app import config  

05: 

06: # Initialise l’application Flask  

07: app = Flask( __name__ )  

08: app.config.from_object(config)  

09:  

10: # Création d’autres ressources  

11: # db = SQLAlchemy( app )  

12: # babel = Babel( app )  

13: 

14: from app import views  

15: from app import models

Le fichier runapp.py

Optionnel, le fichier runapp.py situé dans le répertoire racine du projet permet de démarrer facilement l’application Flask avec le paramétrage souhaité.

L’avantage d’un tel fichier est qu’il n’est pas nécessaire de mémoriser comment démarrer l’application Flask. Son nom laisse clairement deviner son utilité !

Le fichier runapp.py est très simple, il importe l’instance app (créé dans __init__.py) du package app (le répertoire app dans le projet).

Ensuite, la méthode run() est appelée sur cette instance pour démarrer le projet.

#!/usr/bin/env python  

# coding: utf8  

 

from app import app  

 

app.run( debug=True, host=’0.0.0.0’, port=5000 )  

# Production  

#app.run( host=’0.0.0.0’ )  

La toute première ligne inclut un shebang qui indique au système l’interpréteur de commande à utiliser (Python).

Cela permet d’exécuter directement le script depuis une ligne de commande avec « ./runapp.py » si l’attribut d’exécution est activé sur ce script Python avec la commande chmod +x runapp.py.

L’exécution du script produit le résultat suivant dans la console lorsque la page est générée.

pi@pythonic:~/minimal-app $ ./runapp.py  

 * Running on http://0.0.0.0:5000/  

 * Restarting with reloader  

192.168.1.22 - - [06/Jul/2018 13:15:42] "GET / HTTP/1.1" 200 -  

192.168.1.22 - - [06/Jul/2018 21:56:57] "GET / HTTP/1.1" 200 -

La page générée en appelant la page racine du site mis à disposition par l’application Flask sur le Raspberry Pi ressemble à ceci :

images/06RI05.pngimages/06RI05.png
 

Résultat produit par route (’/’) avec inclusion de PARAMS

 

L’adresse IP du Raspberry Pi peut être utilisée en lieu et place du nom de la machine sur le réseau. Tel que défini dans le script runapp.py, le port utilisé est 5000.

Tout comme n’importe quel script Python, runapp.py peut être interrompu à l’aide de la combinaison de touches [Ctrl] C.

3. Déboguer avec Flask

Le contenu de l’application minimale va être modifié de sorte à produire une erreur.

Ensuite, le débogueur de Flask sera activé afin de pouvoir capturer l’erreur.

Une copie du fichier flask-debugger.py, dont le code est repris ci-dessous, est disponible dans le dépôt GitHub du projet dans le sous-répertoire python/flask-demos/.

# coding: utf8  

# Importer la bibliothèque Flask  

from flask import Flask  

 

# Initialise l’application Flask  

app = Flask( __name__ )  

 

@app.route(’/’)  

def racine():  

  return ’Appeler /kaboum pour créer une erreur!’.decode(’utf8’)  

 

@app.route(’/kaboum’)  

def kaboum():  

   val1 = 10  

   val2 = 0  

   # Créer une erreur "division par 0"  

   return ’Résultat = %s’.decode(’utf8’) % (val1/val0)  

 

# Demarrer l’application sur le port 5000  

app.run( debug=True, port=5000, host=’0.0.0.0’)

La route @app.route(’/kaboum’) est destinée à produire volontairement une exception ZeroDivisionError.

La dernière ligne du script démarre l’application Flask en activant le débogueur avec le paramètre debug=True.

Après avoir démarré l’application Flask avec python flask-debugger.py, l’appel de l’URL http://192.168.1.210:5000/kaboum produit l’affichage d’une erreur et l’activation du débogueur Flask. Ce dernier affiche la pile d’appels avec le détail de l’erreur.

images/06RI16.pngimages/06RI16.png
 

Activation du débogueur Flask et affichage de la pile d’appel

Placer la souris au-dessus du détail d’une ligne (par exemple la ligne 17, celle provoquant l’erreur) affiche deux icônes d’options visibles sur la droite.

images/06RI17.pngimages/06RI17.png
 

Déplacer la souris au-dessus d’une ligne active les icônes d’option

Ces icônes permettent respectivement d’activer une « console Python » et d’inspecter le code.

Inspecter le code dans le débogueur

images/06RI19.pngimages/06RI19.png
 

Icône inspection code

En cliquant sur l’icône d’inspection, le débogueur affiche le code source du script correspondant à la ligne ciblée.

images/06RI20.pngimages/06RI20.png
 

Affichage du code source du script Python

Console Python

images/06RI18.pngimages/06RI18.png
 

Icône de la Console Python

En cliquant sur l’icône Console Python, le débogueur active une ligne de commande interactive dans le code Python et dans le contexte de l’application Flask.

La console Python permet d’inspecter les variables, les objets et leurs états, d’évaluer des expressions, permet la création d’instances, d’effectuer des appels de fonctions et de méthodes.

images/06RI21.pngimages/06RI21.png
 

Utilisation de la console Python

La console Python offre également la fonction dump() très pratique et pouvant être utilisée de deux façons différentes :

4. Application Flask en production

Lors d’une mise en production de l’application (ou lorsque celle-ci accepte des connexions externes), il est vivement recommandé de désactiver le débogueur Flask.

L’activation ou non du débogueur a lieu au moment de l’exécution de l’instance Flask avec sa méthode run().

Le débogueur Flask est désactivé en omettant le paramètre debug ou en le plaçant volontairement à False.

# coding: utf8  

# Importer la bibliothèque Flask  

from flask import Flask  

 

# Initialisze l’application Flask  

app = Flask( __name__ ) 

...  

...  

...  

# Demarrer l’application sur le port 5000  

app.run( debug=False, port=5000, host=’0.0.0.0’)  

Si l’application Flask était démarrée avec le paramètre debug=False, alors l’appel de l’URL http://192.168.1.210:5000/kaboum (voir exemple au point précédent) produit toujours une exception, mais le détail de celle-ci est masqué derrière une page d’erreur 500 (Erreur interne serveur).

images/06RI22.pngimages/06RI22.png
 

Désactiver le mode de débogage affiche une erreur 500 en cas d’erreur

Les fondamentaux de Flask

Afin de simplifier la compréhension des exemples, la découverte des fondamentaux de Flask utilise principalement l’approche minimaliste, à savoir dans un unique script Python.

Il faut cependant garder en mémoire que ces fondamentaux prennent néanmoins place dans la structure des répertoires d’un projet Flask (cf. Anatomie d’un projet Flask dans ce chapitre). En conséquence, les exemples de code, de fonctions et de décorateurs devraient prendre place dans une organisation de fichiers similaire à mon-projet/app/views.py.

1. Routes et paramètres

Déjà abordé à plusieurs reprises, le décorateur route permet d’associer une fonction de traitement à une URL. Les décorations route prennent généralement place dans un fichier nommé views.py (ou routes.py).

Le décorateur route prévoit le passage de paramètres dans l’URL, paramètres transmis à la fonction de traitement.

L’exemple ci-dessous présente différents cas de capture de paramètres sur les requêtes.

L’exemple est également disponible sur le dépôt GitHub du projet à l’emplacement suivant : /python/flask-demos/url-params/flask-url-params.py.

01: # coding: utf8  

02: # Importer la bibliothèque Flask  

03: from flask import Flask, request  

04:  

05: # Initialiser l’application Flask  

06: app = Flask( __name__ )  

07:  

08: # Définir une route pour capturer la requête /param  

09: @app.route(’/param/<version_id>’)  

10: def montrer_parametre( version_id = -1):  

11:    # retourne du contenu sous format texte  

12:    return ’Le paramètre est %s’.decode(’utf8’) % version_id  

13:  

14: @app.route(’/prendre-id/<element_id>’)  

15: @app.route(’/prendre/<element_nom>’)  

16: def prend_element( element_id = None, element_nom = None):  

17:    # traiter les cas d erreur  

18:    if not( element_id or element_nom ):  

19:        return ’bad request!’, 400  

20:     

21:    # retourne du contenu sous format texte  

22:    if element_id:  

23:       return ’Le paramètre est numérique avec  

24:            %s’.decode(’utf8’) % element_id  

25:    else:  

26:       return ’Le paramètre est textuel avec  

27:            %s’.decode(’utf8’) % element_nom  

28:  

29: # Utilisation structure plusieurs?nom=Mhoa&prenom=Champion  

30: @app.route(’/plusieurs’)  

31: def plusieurs():  

32:    param_nom = request.args.get(’nom’)  

33:    param_prenom = request.args.get(’prenom’)  

34:  

35:    return "Test de plusieurs parametres." + \  

36:           " Nom=%s et Prénom=%s".decode(’utf8’) %  

37:                (param_nom, param_prenom)  

38:  

39: # Plusieurs paramètres dans la route  

40: @app.route(’/plusieurs2/<nom>/<prenom>’)  

41: def plusieurs2(nom,prenom):  

42:    return "2ième test parametres.".decode(’utf8’) + \  

43:           " Nom=%s et Prénom=%s".decode(’utf8’) % (nom,  

44:                prenom)  

45:  

46: @app.route(’/demo/<int:id>’)  

47: def montrer_demo( id ):  

48:    # retourne du contenu sous format texte  

49:    return ’Le paramètre entier demo est %s’.decode(’utf8’)  

50:         % id  

51:  

52: @app.route(’/demo/<string:id>’)  

53: def montrer_demo2( id ):  

54:    # retourne du contenu sous format texte  

55:    return ’Le paramètre string demo est %s’.decode(’utf8’)  

56:         % id  

57:  

58:  

59: # Démarrer l’application sur le port 5000  

60: app.run( debug=True, port=5000, host=’0.0.0.0’)

 

Si le paramètre communiqué est un entier http://192.168.1.210:5000/demo/18, alors c’est la route de la ligne 46 qui capture la requête. Si le paramètre est une chaîne de caractères http://192.168.1.210:5000/demo/un-mot, alors c’est la route de la ligne 52 qui capture la requête. À noter qu’une valeur en virgule flottante (3.1415) n’étant pas un entier, c’est la route « /demo/<string:id> » qui capturera la requête.

Démarrer le script

Une fois le script démarré avec la commande python flask-url-params.py, il est possible de tester les différents cas de figure énumérés ci-dessous.

 

Bien que l’adresse IP soit utilisée dans les requêtes ci-dessous, l’utilisateur reste libre d’utiliser le nom d’hôte (ex. : pythonic.local) en lieu et place de l’adresse IP (192.168.1.210). Le port utilisé est 5000 comme précisé dans l’instruction app.run().

Route avec un paramètre

La route @app.route(’/param/<version_id>’) permet de capturer une URL comme celle-ci : http://192.168.1.210:5000/param/faire-un-test

Ce qui affiche le résultat suivant :

images/06RI06.pngimages/06RI06.png
 

Capturer un paramètre sur une route

Où il est possible de constater l’inclusion du paramètre dans la requête.

Bien que la fonction montrer_parametre( version_id = -1 ) dispose d’un paramètre avec une valeur par défaut, il n’est pas possible d’appeler l’URL sans paramètre (/param), car il n’y a pas de route correspondante permettant de capturer cette URL (ex. : @app.route(’/param’)).

images/06RI07.pngimages/06RI07.png
 

Exemple d’erreur lorsqu’il n’y a pas de route définie correspondant à la requête

Plusieurs routes - une seule fonction de traitement

Dans l’exemple « /prendre... », plusieurs routes différentes sont associées à une seule fonction de traitement. Ce qui peut être le cas lors d’une recherche d’un élément sur un identifiant unique (ex. : un id interne) ou sa représentation alternative (ex. : code-barre ou nom d’article) que la fonction résout vers l’identifiant unique avant de produire la réponse.

La route @app.route(’/prendre-id/<element_id>’) permet de capturer une URL comme celle-ci : http://192.168.1.210:5000/prendre-id/120

images/06RI08.pngimages/06RI08.png
 

Capture d’une URL pour un paramètre numérique

Alors que la route @app.route(’/prendre/<element_nom>’) permet, elle, de capturer une URL différente avec cette fois un paramètre de type texte.

Par exemple :

http://192.168.1.210:5000/prendre/chaussette

Ce qui produit le résultat suivant :

images/06RI09.pngimages/06RI09.png
 

Capture d’une URL pour un paramètre alphanumérique

Route avec plusieurs paramètres

Il est également possible d’utiliser une route telle que @app.route(’/plusieurs2/<nom>/<prenom>’) permettant de réceptionner plusieurs paramètres.

Par exemple :

http://192.168.1.210:5000/plusieurs2/Dino/Casimir

Ce qui produit le résultat suivant :

images/06RI10.pngimages/06RI10.png
 

Capture de plusieurs paramètres sur une route

Bien que pratique, cette approche a l’inconvénient de devoir prévoir tous les cas de figure à l’avance (absence de l’un ou l’autre des paramètres) afin de dresser une liste de toutes les routes possibles. En effet, la saisie de http://192.168.1.210:5000/plusieurs2/Dino produira une erreur 404 (Page Not Found), car il y manque le second paramètre.

Capture des paramètres de la query string

La query string est la partie de l’URL ne faisant pas partie du chemin d’accès à la ressource. Par exemple, dans l’URL http://exemple.be/chemin/vers/page?nom=pythonic&logo=serpent, la query string débute après le caractère « ? » et contient « nom=pythonic&logo=serpent ». La query string contient des paramètres sous la forme « clé=valeur », paramètres séparés par une esperluette (&). Dans l’exemple précité, il y a deux paramètres : nom et logo dont les valeurs sont respectivement pythonic et serpent.

Le micro framework Flask permet de récupérer facilement les paramètres de la query string comme le démontre la route @app.route(’/plusieurs’).

La route permet de capturer la requête http://192.168.1.210:5000/plusieurs mais accepte également les URL avec paramètres dans la query string comme http://192.168.1.210:5000/plusieurs?nom=Dino&prenom=Roustin.

images/06RI11.pngimages/06RI11.png
 

Capture de paramètres sur la query string

Les valeurs des paramètres de la query string peuvent être extraites à l’aide de l’objet request (par exemple : param_prenom = request.args.get(’prenom’)).

L’objet request offre l’avantage de permettre une capture sans erreur des paramètres dans la query string, même si ceux-ci en sont absents.

Contrairement à la route @app.route(’/plusieurs2/<nom>/<prenom>’), la route @app.route(’/plusieurs’) permet d’exécuter la fonction plusieurs() avec ou sans paramètre comme le démontre la capture suivante.

images/06RI12.pngimages/06RI12.png
 

Exemple de capture sans paramètre dans la query string

Routes avec des paramètres typés

Le script de démonstration définit deux routes « /demo/<int:id> » et « /demo/<string:id> » correspondant respectivement aux fonctions montrer_demo( id ) et montrer_demo2( id ). À noter que les deux routes présentes ont une racine identique (/demo) et que la seule différence réside dans le type du paramètre.

La saisie de l’URL http://192.168.1.210:5000/demo/18 produit le résultat suivant où le message indique clairement que c’est le paramètre sous forme d’entier qui a été capturé. C’est la fonction montrer_demo() qui a traité la requête.

images/06RI13.pngimages/06RI13.png
 

Capture d’un paramètre typé comme entier

Par contre, la saisie de l’URL http://192.168.1.210:5000/demo/un-mot produit le résultat suivant où, cette fois, le message indique que la donnée est une chaîne de caractères (requête prise en charge par la fonction montrer_demo2().

images/06RI14.pngimages/06RI14.png
 

Capturer un paramètre typé comme une chaîne de caractères

À noter qu’il n’y a pas de prise en charge spécifique pour une valeur à virgule flottante et par conséquent, l’URL http://192.168.1.210:5000/demo/3.1415 sera prise en charge par le type de donnée le plus adapté qui est la chaîne de caractères.

images/06RI15.pngimages/06RI15.png
 

Cas de la valeur en virgule flottante

Les types de paramètres des routes

Flask supporte plusieurs types de paramètres, appelés converters dans la littérature sur le sujet :

2. Retourner une erreur

Retourner une page d’erreur avec Flask est assez simple. Le module flask propose la fonction abort() qui permet d’interrompre le fonctionnement d’une fonction de traitement en retournant un code d’erreur HTML.

Dans le script suivant, la route @app.route(’/mon-erreur/<int:id>’) permet de retourner une erreur 404 (Page non trouvée) si le paramètre id est supérieur à 99.

# coding: utf8  

# Importer la bibliothèque Flask  

from flask import Flask, abort  

 

# Initialisze l’application Flask  

app = Flask( __name__ )  

 

@app.route(’/’)  

def racine():  

  return ’Appeler /mon-erreur avec id<100 ou 

id>=100’.decode(’utf8’)  

 

@app.route(’/mon-erreur/<int:id>’)  

def demo( id ):  

   if id > 100:  

       # Retourner une page d’erreur 404  

       abort(404)  

   else:  

       return ’C est tout bon’  

 

# Demarrer l’application sur le port 5000  

app.run( debug=True, port=5000, host=’0.0.0.0’)

La fonction abort() prend un code d’erreur HTML ou un objet Response en paramètre et termine prématurément le traitement de requête.

L’utilisation la plus commune de la fonction abort() est le code d’erreur HTML (400 et suivant).

Les codes d’erreur HTML concernés sont :

Voir aussi le document rfc2616 qui définit les codes de statut : https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html

Par exemple :

3. Utilisation de template

Une puissante fonctionnalité de Flask est l’opportunité d’utiliser le moteur de template Jinja pour faciliter la production de contenu.

Le moteur de template Jinja permet d’effectuer des remplacements à la volée dans des templates HTML (ou autres) stockés dans le sous-répertoire app/templates/ du projet. La fonction de traitement de la requête peut passer des données, des objets et des structures Python au template, ou ces éléments sont directement exploitables.

Les templates Jinja font l’objet d’un développement plus étendu dans la section Templates Jinja, l’exemple ci-dessous définit la route par défaut « / » en faisant appel au template demovar.html pour afficher le contenu des variables.

L’exemple est également disponible sur le dépôt GitHub du projet à l’emplacement suivant : /python/flask-demos/template-app/.

Le projet est constitué comme suit :

.  

├── app  

│   ├── __init__.py  

│   ├── static  

│   ├── templates  

│   │   └── demovar.html  

│   └── views.py  

└── runapp.py

Le fichier __init__.py initialise le package app. Sans surprise, il charge le fichier views.py pour définir la prise en charge de différentes routes.

# coding: utf8  

# Importer la bibliothèque Flask  

from flask import Flask  

 

# Initialisze l’application Flask  

app = Flask( __name__ )  

 

# Prise en charge des requêtes  

from app import views

Le fichier views.py définit deux routes, la racine « / » et la racine avec un paramètre complémentaire « /<string:nom> » (un nom à afficher).

# coding: utf8  

from app import app  

from flask import render_template  

 

@app.route(’/’)  

@app.route(’/<string:nom>’)  

def demovar( nom = None ):  

  items = [’banane’, ’orange’, ’pomme’, ’poire’]  

  return render_template( ’demovar.html’, name=nom, 

    elements=items )

La fonction demovar( nom=None ) peut être appelée avec un paramètre initialisé via la route « /<string:nom> » ou sans paramètre (et donc avec la valeur par défaut None) via la route « / ».

La fonction demovar(...) crée une liste d’éléments nommés items puis demande le rendu du template demovar.html qui sera retourné en réponse. Le template reçoit différents paramètres nommés ; l’un appelé name correspond à la variable locale nom, l’autre nommé elements correspond à la variable locale items. Le template pourra donc accéder à deux variables : name et elements. Ce petit jeu de passe-passe met en lumière la différence entre « variable de la fonction de traitement » et « variable de template » !

Le template demovar.html s’occupe du rendu HTML. Le fichier contient des balises HTML et des balises Jinja {% ... %} {{ ... }} interprétées par le moteur de rendu :

<!DOCTYPE HTML>  

<html>  

  <head>  

     <title>Demo var</title>  

  </head>  

  <body>  

       <h1>Dit bonjour</h1>  

           {% if name %}  

             <p>Bonjour {{ name }}!</p>  

           {% else %}  

             <p>Bonjour à tous!</p>  

           {% endif %}       

           <h1>Fruits</h1>  

           <ul>  

                {% for el in elements %}  

                <li>{{ el }}</li>  

                {% endfor %}  

           </ul>  

  </body>  

</html>

La balise {% if name %} permet d’afficher un contenu conditionnel si la variable name est initialisée, variable passée en paramètre lors de l’appel du template. La balise {% if ... %} est terminée par une balise {% endif %} et peut être utilisée conjointement avec un {% else %}.

Les balises {% for item in collection %} et {% endfor %} permettent d’énumérer le contenu d’une liste ou d’une collection et de répéter le contenu de la section pour chaque élément de la collection.

La balise {{ élément_a_évaluer }} permet d’évaluer un élément et d’en insérer le contenu dans le document de sortie.

Pour finir, le script runapp.py permet de démarrer facilement l’application Flask (avec python runapp.py) :

#!/usr/bin/env python  

# coding: utf8  

 

from app import app  

 

app.run( debug=True, host=’0.0.0.0’, port=5000 )  

# Production  

#app.run( host=’0.0.0.0’ )  

Voici ce que produit le template lorsqu’il n’y a pas de paramètre communiqué dans l’URL (http://192.168.1.210:5000/).

images/06RI24.pngimages/06RI24.png
 

Génération d’une page à l’aide d’un template

Dans ce premier cas de figure, le paramètre nom = None lors de l’appel de demovar(), ce qui entraîne une valeur None pour la variable name dans le template (cf. appel de render_template). 

Le second cas de figure passe un paramètre dans l’URL appelée (http://192.168.1.210:5000/Dominique). Dans ce cas, le paramètre nom = Dominique lors de l’appel de demovar(), cette valeur est transmise à la variable name du template. La variable name étant initialisée, la balise {% if name %} modifie le comportement du template pour afficher un message différent.

images/06RI25.pngimages/06RI25.png
 

Modification du comportement du template

4. Création d’URL

Le micro framework Flask inclut la fonction url_for très pratique pour créer des URL à partir des routes définies dans l’application.

Cela évite d’avoir à coder les URL en dur dans le script Python et dans les templates Jinja.

Les requêtes

La fonction url_for() prend le nom de la fonction de traitement en paramètre et génère l’URL associée. Les décorateurs @route() associant les fonctions de traitement aux différentes routes (URL), la fonction url_for() dispose donc de toutes les informations nécessaires à la résolution du nom de fonction vers URL.

Par exemple, si la route suivante est définie :

@app.route(’/demo’)  

def montrer_demo():  

   return "Voici la page de démo".decode(’utf8’)

Alors il est possible de générer l’URL à partir de :

url_vers_demo = url_for( ’montrer_demo’ )

La fonction url_for() permet également de composer des URL avec paramètres.

Par exemple, si la route suivante est définie :

@app.route(’/bonjour/<nom>/<prenom>’)  

def dit_bonjour( nom, prenom ):  

   return "Je dis bonjour a %s %s".decode(’utf8’) % (nom, prenom)

Alors il est possible de générer l’URL à partir de :

url_vers_bonjour = url_for( ’dis_bonjour’, prenom=’Antoine’, nom=’Couture’ )

Ressources statiques

La fonction url_for() permet également de produire des URL vers les fichiers statiques, ceux stockés dans le répertoire /app/static/ d’un projet Flask.

Dans un projet constitué comme suit :

.  

├── app  

│   ├── __init__.py  

│   ├── static 

│   │   ├── website.css 

│   │   └── blog  

│   │       └── girly.css  

│   ├── templates  

│   │   └── index.html  

│   └── views.py 

└── runapp.py

Le fichier website.css dans le répertoire static est accessible via l’URL composée à l’aide de :

url_for( ’static’, filename=’website.css’ )

Le fichier girly.css dans le répertoire static/blog est accessible via l’URL composée à l’aide de :

url_for( ’static’, filename=’blog/girly.css’ )

5. Redirection

La redirection permet d’envoyer une réponse particulière au navigateur. Cette réponse indique au navigateur qu’il doit charger une autre page. Cela s’appelle une « redirection ».

La fonction redirect() est généralement utilisée conjointement avec url_for() pour rediriger le navigateur vers la liste des enregistrements après avoir inséré une nouvelle entrée dans l’application.

Le pseudo-code ci-dessous présente l’utilisation de la fonction redirect().

# coding: utf8  

...  

from flask import render_template, request, redirect 

from flask import url_for, flash, abort, Response  

 

@app.route(’/’)  

def main():  

   ...  

   return render_template( ’dash_list.html’, dash_list=rows )  

 

@app.route(’/dashboard/add’, methods=[’GET’,’POST’] )  

def dashboard_add():  

 if request.method == ’GET’:  

   # --- GET ---  

   ...  

   return render_template( ’dash_edit.html’, row=row )  

 else:  

   # --- POST ---  

   ...  

   if( request.form[’action’] == u’cancel’ ):  

     # Abandon  

     return redirect( url_for(’main’) )  

 

   app.logger.debug( ’saving dash %s’, data )  

   ... 

   return redirect( url_for(’main’) ) 

 

 

# -- Second exemple -------------------------------------- 

 

@app.route(’/sessionvars’ )  

def viewsession():  

 resp = ...   

 return resp  

 

@app.route(’/sessionremove/<name>’)  

def removesession( name ):  

 session.pop( name ) 

 return redirect( url_for(’viewsession’) )

La fin du script avant le second exemple effectue la sauvegarde de l’enregistrement, puis retourne une réponse de redirection avec return redirect( url_for(’main’) ).

 

Lors d’une redirection, le serveur web renvoie l’URL de destination dans l’en-tête de la page HTML. Il n’y a donc pas de contenu HTML renvoyé au navigateur. La fonction redirect() utilise le statut HTML 302 (la ressource demandée réside temporairement à une URI différente).

Utilisation avancée

Le prototype de redirect est :

def redirect(location, code=302, Response=None)

Les codes HTML supportés par redirect sont :

Voir aussi le document rfc2616 qui définit les codes de statut : https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html

Redirection et sécurité

Il est assez commun d’utiliser une redirection pour renvoyer l’utilisateur vers la page d’origine après une opération de login. Il y a deux approches pour réaliser ce type d’opération :

1.

Utiliser le referrer de la page au moment du rendu de la page de login.

2.

Utiliser un champ NEXT indiquant la page suivante après une opération de login.

Dans les deux cas, il faut s’assurer qu’il n’y a pas d’utilisation malveillante de la fenêtre de login et que le client n’est pas redirigé vers un site malveillant. Il est donc important de réaliser des vérifications complémentaires sur l’URL proposée avant d’opérer la redirection en fonction d’un champ (ou referrer HTML).

L’article « Securely Redirect Back » (http://flask.pocoo.org/snippets/62/) des Snippets Flask aborde ce sujet en détail.

6. Requêtes GET et POST

Les routes définies dans l’application Flask acceptent, par défaut, les requêtes de type GET. Les requêtes GET permettent de collecter des informations depuis l’application Flask.

Lorsqu’une page web a besoin d’envoyer des informations vers l’application Flask, celle-ci utilise un formulaire HTML et une requête de type POST.

 

Flask supporte également les autres types de requêtes prévues par le W3C, à savoir DELETE, PUT, HEAD en plus de GET, POST.

Flask accepte les requêtes POST si cela est précisé dans le décorateur route.

@app.route(’/demo/<id>’, methods = [’GET’, ’POST’])  

def demo(id):  

  if request.method == ’GET’:  

     # Générer la page pour la valeur "id"  

     ...   

     return la_page_html  

       

  if request.method == ’POST’:  

     # Modifier les informations pour "id"   

     donnee = request.form # un dictionnaire avec les valeur  

     ...  

     return la_page_de_confirmation

L’application suivante propose d’utiliser les requêtes de type GET et POST pour saisir un message, l’envoyer à l’application Flask (qui devrait l’envoyer sur un système de messagerie), puis produire une page de réponse.

images/06RI26.pngimages/06RI26.png
 

Diagramme des échanges entre le navigateur et l’application Flask - réalisé avec draw.io

L’exemple flask-post.py est également disponible sur le dépôt GitHub du projet à l’emplacement /python/flask-demos/.

01: # coding: utf8  

02: # Importer la bibliothèque Flask  

03: from flask import Flask, Response, url_for, request  

04:  

05: # Initialise l’application Flask  

06: app = Flask( __name__ )  

07:  

08: # Définir une route pour capturer la requête  

09: # /message produire la réponse avec la fonction  

10: # message()  

11: @app.route(’/message’, methods=[’GET’, ’POST’] )  

12: def message():  

13:    if request.method == ’GET’: 

14:       return Response( """<!DOCTYPE html>  

15: <html>  

16: <body>  

17: <h1>Saisir un message</h1>  

18:  <form action="%s" method="post">  

19:   Destinataire:<br>  

20:   <input type="text" name="dest"><br>  

21:   Message:<br>  

22:   <input type="text" name="msg"><br>  

23:   <input type="submit" value="Envoyer">  

24: </form>  

25:  

26: </body>  

27: </html>""".decode(’utf8’) % url_for(’message’) )  

28:  

29:    if request.method == ’POST’:  

30:       donnee = request.form  

31:       le_message = donnee[’msg’]  

32:       le_destinataire = donnee[’dest’]  

33:       # effectuer l’envoi  

34:       # ...  

35:  

36:       # renvoyer la réponse  

37:       return Response( """<!DOCTYPE html>  

38: <html>  

39: <body>  

40: <h1>Message envoyé</h1>  

41: Le message "%s" à été envoyé à %s.  

42: </body>  

43: </html>""".decode(’utf8’) % (le_message, le_destinataire) )  

44:  

45: # Demarrer l’application sur le port 5000  

46: app.run( debug=True, port=5000, host=’0.0.0.0’)

Une fois l’application Flask démarrée avec la commande python ./flask-post.py, il est possible de la tester en saisissant l’URL http://192.168.1.210/message. L’appel effectue une requête de type GET sur la route @app.route(’/message’, methods=[’GET’, ’POST’] ), ce qui produit la page de saisie du message.

images/06RI27.pngimages/06RI27.png
 

Générer le formulaire HTML sur une requête de type GET.

Une fois les informations saisies dans la fenêtre et le bouton Envoyer pressé, l’URL http://192.168.1.210/message est rappelée avec, cette fois, une requête de type POST.

images/06RI28.pngimages/06RI28.png
 

Données saisies dans le formulaire HTML

Ce qui produit le contenu suivant :

images/06RI29.pngimages/06RI29.png
 

Confirmation produite suite à une requête de type POST

Bien que correcte sur le plan fonctionnel, la manipulation directe des champs de données et des balises form HTML ouvre la voie à de nombreuses tentatives de cracking de votre application.

 

Le micro framework Flask supporte également l’extension Flask-WTF. La bibliothèque contient WTForms qui permet de réaliser le rendu et la validation de formulaires HTML avec une grande flexibilité. Cette bibliothèque est très puissante et vaut la peine de s’y intéresser dès lors que l’application est accessible depuis Internet. Voir https://flask-wtf.readthedocs.io/en/stable/ pour plus d’informations.

7. Contexte applicatif

Le contexte applicatif est destiné à maintenir des informations relatives à l’application durant l’exécution d’une requête. C’est donc un emplacement idéal pour stocker des données à partager avec tous les intervenants (views, template) du traitement d’une requête.

Lorsque Flask reçoit une requête, il crée un contexte applicatif puis un contexte de requête (qui stocke des informations relatives à la requête). La requête est traitée et une fois achevée, le contexte de requête est libéré puis le contexte applicatif. Le contexte applicatif a donc une durée de vie identique à celle du traitement de la requête.

Par ailleurs, lorsque Flask reçoit une requête via WSGI, celui-ci crée un fil d’exécution (thread, coroutine, etc.) pour traiter celle-ci. Le fil d’exécution est initialisé avec les informations de la requête, ce qui permet de mettre en place le contexte applicatif et le contexte de requête. Cela signifie donc que le contexte applicatif et contexte de requête sont tous deux isolés des autres requêtes gérées par Flask.

a. L’objet g

Le contexte applicatif expose un objet g permettant d’initialiser et de maintenir des informations durant tout le traitement d’une requête.

Sans surprise, le cas d’utilisation le plus fréquent est la création et le maintien d’une connexion vers une base de données, mais l’objet g permet de stocker bien d’autres informations.

La documentation Flask recommande l’utilisation du modèle suivant pour créer, utiliser et libérer les objets dans l’objet g :

 

accesseur vs getter : la fonction "getter" est plus communément connue sous le terme "accesseur" dans la littérature française. "getter" reste cependant un anglicisme très répandu chez les développeurs.

b. Connexion à la base de données

Le pseudo-code ci-dessous met en évidence les manipulations du contexte applicatif pour gérer une connexion vers une base de données.

From flask import g 

 

# retrouver la base de données 

def get_bdd() : 

  # Si objet de base de données pas encore créé 

  if ’bdd’ not in g : 

     # Créer l’objet de base de données et connecter la DB 

     g.bdd = connecter_bdd() 

 

 # retourner la reférence vers l’objet de base de données 

 return g.bdd 

 

@app.teardown_appcontext 

def teardown_app(exception): 

 # récupérer objet de base de données s’il existe sinon None 

  bdd = g.pop( ’bdd’, None ) 

  # si objet existe alors fermer la connexion. 

  if bdd : 

     fermer_bdd( bdd ) 

 

  # D’anciennes implémentations de Flask de disposent 

  # pas encore de g.pop(...), il faut alors procéder  

  # comme suit : 

  # bdd = g.get( ’bdd’, None ) 

  # if dbb : 

  #    fermer_bdd( bdd ) 

  #    del( bdd )

Grâce à la fonction get_bdd(), tous les appels obtiennent la même connexion vers la base de données. La fonction teardown_app() permet de clôturer la connexion sur la base de données si celle-ci est créée.

8. Les cookies

Les cookies permettent de stocker des informations dans le navigateur. Ces informations concernent le client et sa navigation sur le site et les cookies sont utilisés dans le but d’améliorer l’expérience utilisateur.

Les cookies stockent également le nom de domaine du site et une date d’expiration, les données stockées dans le cookie ne concernent que votre site.

Flask permet de manipuler les cookies à l’aide d’outils faciles d’emploi. L’initialisation et la modification des cookies sont effectuées lors du renvoi d’une réponse vers le client.

Initialiser un cookie

Pour initialiser un cookie, la fonction de traitement doit créer un objet Response à l’aide de la fonction make_response(). L’objet Response expose la méthode set_cookie() qui permet d’initialiser la valeur d’un cookie.

@app.route(’/login’, methods=[’POST’,’GET’] )  

def do_login():  

  if request.method == ’GET’:  

      return render_template( ’login.html’)  

  if request.method == ’POST’:  

      user = request.form[’user_name’]  

      pswd = request.form[’user_pswd’]  

       

      user_id = user_login( user, pswd )  

      if not( user_id ):  

          return render_template( ’loginerror.html’ )  

 

      # Initialiser les cookies  

      rep = make_response( render_template(’main.html’) ) 

      rep.set_cookie( ’userid’, user_id ) 

      return rep

Récupérer un cookie

Les cookies sont communiqués par le navigateur lors de chaque requête. Ils sont donc disponibles sur l’objet request qui les expose à l’aide de la propriété cookies.

@app.route(’/userinfo’ )  

def show_user_info():  

  # récupération de la valeur du cookie 

  user_id = request.cookies.get( ’userid’ ) 

  if not( user_id ):  

      return render_template( ’error.html’)  

  else:  

      user_obj = get_user_info( user_id )  

      return render_template( ’userinfo.html’, user=user_obj )

Redirection et cookie

Les cookies sont assignés sur l’objet Response contenant la page HTML à renvoyer. Or, la fonction redirect() ne crée pas d’objet Response ! A priori, il n’est pas possible de modifier la valeur des cookies lorsque l’on effectue une redirection à moins d’utiliser un tour de passe-passe.

from flask import make_response, redirect, url_for  

if la_condition :  

   response = make_response( redirect(url_for(’main’)) )  

   response.set_cookie( ’nom_parametre’,  

                        la_valeur_dans_le_cookie )  

   return response

Exemple

L’exemple flask-cookie.py disponible sur le dépôt GitHub du projet à l’emplacement /python/flask-demos/ permet de tester et de manipuler les cookies.

9. Les sessions

Flask prend en charge un support de session qui, en opposition avec les cookies, permet de stocker des informations côté serveur.

La session est généralement utilisée pour stocker des informations sur l’utilisateur entre la connexion (login) et la déconnexion (logout) de celui-ci sur l’application Flask.

Même si cela n’est pas directement visible, la session s’appuie sur la gestion des cookies pour identifier la session. En effet, un identifiant de session (session ID) est encrypté et stocké dans les cookies de l’utilisateur. Grâce à lui, Flask est capable d’identifier la session lors de chaque requête et peut recharger les éléments de la session côté serveur.

L’utilisation d’une session requiert donc la définition d’une clé secrète (SECRET_KEY) dans la configuration de l’application Flask.

app = Flask( __name__ )  

app.config[’SECRET_KEY’] = ’la-cle-secrete’

Les informations de session sont manipulées aussi simplement qu’un dictionnaire Python.

from flask import session  

 

# assignation  

session[’user_id’] = id_utilisateur  

 

# extraction  

id = session[’user_id’]  

 

# Tester la présence  

if ’user_id’ in session:  

   id = session[’user_id’]

Une variable de session peut être enlevée à l’aide de méthode pop(var_name).

session.pop(’user_id’)

Exemple

L’exemple flask-session.py disponible sur le dépôt GitHub du projet à l’emplacement /python/flask-demos/ permet de tester et de manipuler les variables de session.

Stockage des sessions

Par défaut, les sessions sont gérées par le processus Flask, ce qui signifie qu’elles sont réinitialisées à chaque redémarrage de l’application Flask.

L’implémentation du stockage des sessions est néanmoins réalisée par l’intermédiaire d’une interface. Il est donc possible d’altérer la façon dont Flask stocke les données de session pour, par exemple, les stocker dans une base de données ou un serveur Redis.

L’extension Flask-Session permet de stocker les sessions sur différents types de supports tels que :

Voyez la page https://pythonhosted.org/Flask-Session/ pour plus d’informations.

10. Journalisation

Flask propose un logger sur l’application.

# coding: utf8  

# Importer la bibliothéque Flask  

from flask import Flask 

# Initialise l’application Flask  

app = Flask( __name__ ) 

app.logger.info(’Flask app créer. Message d info.’) 

app.logger.error(’Message d erreur.’)

Le micro framework Flask propose une configuration par défaut envoyant tous les messages d’erreurs vers la sortie d’erreur standard (sys.stderr) tel que défini dans la variable d’environnement environ[’wsgi.errors’]. À noter que la configuration par défaut permet uniquement de collecter les messages d’erreur vers la console.

Le logger est initialisé lors du premier accès à la propriété app.logger. Par conséquent, toute modification de la configuration du logger doit intervenir, autant que possible, avant la création de l’objet app.

Loguer toutes les informations

La configuration par défaut ne capture que les messages d’erreurs. Il est pourtant intéressant de pouvoir capturer les autres types de messages (info, debug, warning).

L’exemple ci-dessous propose un logger alternatif nommé « app » (et son formatter associé) permettant de distinguer les messages de l’application des messages du logger racine « root ».

# coding: utf8  

# Importer la bibliothèque Flask  

from flask import Flask 

from logging.config import dictConfig 

from logging import getLogger  

import sys  

 

# ====================================== 

#  Modifier la configuration du Logger 

# ====================================== 

# Lorsqu’exécuté depuis la console avec Werzeug, les handlers  

# stdout et wsgi affichent l’information sur la console  

dictConfig({  

   ’version’: 1,  

   ’formatters’: {  

           ’default’: {  

           ’format’: ’[%(asctime)s] x %(levelname)s in %(module)s: %(message)s’, 

       },  

           ’appformatter’ : {  

           ’format’: ’[%(asctime)s] -(APP)- %(levelname)s in % 

(module)s: %(message)s’,  

       }  

   },  

   ’handlers’: {  

           ’wsgi’: {  

                  ’class’: ’logging.StreamHandler’,  

                  ’stream’: sys.stdout,  

                  ’formatter’: ’default’  

            },  

            ’stdout’: {  

                  ’class’: ’logging.StreamHandler’,  

                  ’stream’: sys.stdout,  

                  ’formatter’: ’appformatter’  

           }  

     },  

     ’loggers’ : {  

         ’root’: {  

            ’level’: ’DEBUG’,  

            ’handlers’: [’wsgi’]  

     },  

         ’app’: {  

            ’level’: ’DEBUG’,  

            ’handlers’: [’stdout’]  

     }  

   }  

})  

 

# Initialise l’application Flask  

app = Flask( __name__ ) 

 

 

# Utiliser le logger racine « root » 

app.logger.info( ’Log on root logger!’ ) 

 

# Utiliser le logger « app »  

getLogger(’app’).info( ’Log on APP logger!’ )  

 

index_template =  """<!DOCTYPE html>  

<html>  

<body>  

<h1>Logger demo</h1>  

Un message d’erreur vient d’être envoyé vers le logger.  

</body>  

</html>""".decode(’utf8’)  

 

@app.route(’/’ )  

def index():  

  getLogger(’app’).error( ’Appel de /’ )  

  resp = app.jinja_env.from_string(index_template).render()  

  return resp  

      

# Demarrer l’application sur le port 5000  

app.run( debug=True, port=5000, host=’0.0.0.0’)

Approche pythonique

En utilisant une approche pythonique, il est tout à fait possible de définir un second logger sur l’objet applicatif. Cela permet d’avoir à disposition ce deuxième logger sans avoir besoin d’appeler systématiquement logging.getLogger( ’app’ ).

Le script ci-dessus devient donc :

# coding: utf8  

# Importer la bibliothèque Flask  

from flask import Flask  

from logging.config import dictConfig  

from logging import getLogger  

import sys  

 

# ====================================== 

#  Modifier la configuration du Logger 

# ====================================== 

# Lorsqu’exécuté depuis la console avec Werzeug, les handlers  

# stdout et wsgi affichent l’information sur la console  

dictConfig({  

   ...  

})  

 

# Initialise l’application Flask  

app = Flask( __name__ ) 

 

 

app.logger.info( ’Log on root logger!’ ) 

app.applogger = getLogger(’app’) 

app.applogger.info( ’Log on APP logger!’ )  

 

index_template =  """<!DOCTYPE html> ... """.decode(’utf8’)  

 

@app.route(’/’ )  

def index(): 

  app.applogger.error( ’Appel de /’ )  

  return app.jinja_env.from_string(index_template).render()  

      

# Demarrer l’application sur le port 5000  

app.run( debug=True, port=5000, host=’0.0.0.0’)

Bien que cela soit tentant d’ajouter de nombreuses propriétés sur app, cela n’est pas recommandé dans les directives de développement Flask.

Logging complémentaire

Certains paquets effectuent des opérations de journalisation de façon intensive. C’est le cas, par exemple, de SQLAlchemy. Il est possible d’alimenter le journal avec une configuration personnalisée de dictConfig pour capturer les messages en ajoutant les instructions suivantes au script :

from flask.logging import default_handler  

 

logging.getLogger(’sqlalchemy’).addHandler(default_handler)

Il est donc opportun de consulter la documentation des extensions Flask, car ces dernières fournissent des informations sur la journalisation pouvant se montrer extrêmement utiles.

Autres exemples

Les exemples flask-logger.py, flask-logger-all.py, flask-logger-all2.py disponibles sur le dépôt GitHub du projet à l’emplacement /python/flask-demos/ permettent de tester et manipuler les différentes options pour journaliser des messages.

11. Mini-projet Fruits

Ce mini-projet démontre la mise en œuvre d’une base de données SQLite 3 dans un projet Flask.

Pour que la démonstration soit satisfaisante, l’exemple va au-delà de la simple connexion SQLite en proposant quelques fonctionnalités de bases comme lister le contenu d’une table de fruits et proposer la modification d’enregistrements. Il mettra en œuvre une connexion SQLite 3, les routes, l’édition de formulaire HTML (GET et POST), des templates Jinja.

Le but du mini-projet est d’offrir un maximum de fonctionnalités avec un minimum de complexité.  

À l’exception des feuilles de style (CSS) et de WTF (saisie et validation de formulaire HTML), cet exemple est ce qui se rapproche le plus d’une vraie application Flask.

images/06RI30.pngimages/06RI30.png
 

Résultat de l’URL racine de l’exemple sqlite-app

images/06RI31.pngimages/06RI31.png
 

Édition d’un enregistrement (exemple sqlite-app)

Explorer les entrailles de ce mini-projet est un excellent point de départ pour comprendre et constituer un autre projet Flask autour d’une base de données.

a. Sources du mini-projet

L’exemple fruits-app est disponible sur le dépôt GitHub du projet à l’emplacement /python/flask-demos/fruits-app.

Il utilise la base de données SQLite 3 food.db stockée dans le répertoire principal à côté du script runapp.py.

Pour lancer facilement l’application, démarrer un terminal dans le répertoire /python/flask-demos/fruits-app, puis saisir python runapp.py pour démarrer l’application Flask.

b. La connexion SQLite 3

Les connexions SQLite ont déjà été abordées précédemment (cf. Persistance des données - SQLite3) et les exemples SQLite abordés dans ce chapitre seraient tout aussi valables.

Il existe cependant une meilleure approche que celle consistant à créer une connexion de base de données dans chaque fonction de traitement (au moment où celle-ci est nécessaire).

Flask expose un contexte applicatif et la variable g permettant de stocker divers éléments facilement accessibles dans le code Python ainsi que dans les templates Jinja. Il est recommandé d’utiliser le contexte applicatif et un getter pour obtenir la connexion à la base de données. En effet, cela permet de restreindre la connexion à une seule et unique instance pour exécuter tous les accès en base de données nécessaires pour le traitement de la requête.

Pour rappel, voici l’approche recommandée dans la section « Contexte applicatif » :

From flask import g 

 

# retrouver la base de données 

def get_bdd() : 

  if ’bdd’ not in g : 

     g.bdd = connecter_bdd() 

 

 return g.bdd 

 

@app.teardown_appcontext 

def teardown_app(exception): 

  bdd = g.pop( ’bdd’, None ) 

  if bdd : 

     fermer_bdd( bdd ) 

 

  # D’anciennes implémentations de Flask ne disposent 

  # pas encore de g.pop(...), il faut alors procéder 

  # comme suit : 

  # bdd = g.get( ’bdd’, None ) 

  # if dbb : 

  #    fermer_bdd( bdd ) 

  #    del( bdd )

Dans l’exemple ci-dessous, l’application Python accède à une base de données SQLite 3 pour afficher le contenu de la table fruits de la base de données food.db. Il est également possible d’ajouter et d’effacer des enregistrements dans la table.

c. Organisation du mini-projet

Le projet est constitué des éléments suivants :

.  

├── app  

│   ├── __init__.py  

│   ├── config.py 

│   ├── views.py 

│   ├── models.py  

│   ├── static  

│   └── templates  

│       ├── fruit_edit.html  

│       └── fruit_list.html  

├── food.db  

└── runapp.py

d. Détails du mini-projet

Le fichier __init__.py

Ce script d’initialisation du paquet effectue les opérations suivantes :

1.

Modification de la configuration de journalisation pour capturer les messages de débogage.

2.

Chargement du fichier views.py pour l’activation des routes.

3.

Chargement du fichier models.py pour l’enregistrement de la fonction teardown_app(). 

# coding: utf8  

# Importer la bibliothèque Flask  

from flask import Flask  

from app.config import configuration  

from logging.config import dictConfig  

 

# Nouvelle config pour capturer  

# tous les niveaux de log  

dictConfig( configuration )  

 

# Initialisze l’application Flask  

app = Flask( __name__ )  

 

# Prise en charge des requêtes  

from app import views  

from app import models

Le fichier models.py

Le script models.py prend en charge toutes les opérations relatives à la base de données.

Il définit le getter de la base de données, get_bdd(), et la fonction teardown_app() qui libère la connexion.

Viennent ensuite les fonctions utilitaires get_fruits(), get_fruit(), insert_fruit(), update_fruit() et drop_fruit().

01: # coding: utf8  

02: from app import app  

03: from flask import g  

04: import sqlite3  

05: # ----------------------------------------  

06: # Accès à la base de données  

07: # ----------------------------------------  

08: def get_bdd():  

09:     if not ’bdd’ in g:  

10:         g.bdd = sqlite3.connect( ’food.db’ )  

11:     return g.bdd  

12:  

13: @app.teardown_appcontext  

14: def teardown_app(exception):  

15:    # Version plus récente de Flask  

16:    #  

17:    #_bdd = g.pop( ’bdd’, None )  

18:    #if( _bdd):  

19:    #   _bdd.close()  

20:     

21:    _bdd = g.get( ’bdd’, None )  

22:    if( _bdd):  

23:       _bdd.close()  

24:       del( _bdd )  

25:  

26: # ----------------------------------------  

27: # Utilitaires  

28: # ----------------------------------------  

29:  

30: def get_fruits():  

31:     """ Obtenir la liste des fruits (id, nom, kcal_100gr)  

32:          """  

33:     cursor = get_bdd().cursor()  

34:  

35:     cursor.execute( "select id, name, kcal_100gr from  

36:          fruits order by name")  

37:     if cursor.rowcount == 0:  

38:         return None  

39:     else:  

40:         # NB: Liste les noms des colonnes  

41:         # colnames = [item[0] for item in  

42:         #     cursor.description ]  

43:  

44:         # retourne une liste de tuples  

45:         return cursor.fetchall()   

46:  

47: def get_fruit( id ):  

48:     """ obtenir les informations d’un fruit donné """  

49:     assert type(id)==int  

50:  

51:     cursor = get_bdd().cursor()  

52:  

53:     cursor.execute( "select id, name, kcal_100gr from  

54:          fruits where id = %s" % id )  

55:     if cursor.rowcount == 0:  

56:         return None  

57:     return cursor.fetchone()  

58:  

59: def insert_fruit( name, kcal ):  

60:     assert type(kcal)==int  

61:  

62:     cursor = get_bdd().cursor()  

63:  

64:     cursor.execute(  

65:         "insert into fruits (name,kcal_100gr) values (?,  

66:              ?)",  

67:         (name, kcal)  

68:         )  

69:     if cursor.rowcount > 0:  

70:         rowid = cursor.lastrowid  

71:     else:  

72:         rowid = None  

73:  

74:     get_bdd().commit()  

75:  

76:     return None  

77:  

78: def update_fruit( id, name, kcal ):  

79:     assert type(id)== int  

80:     assert type(kcal)==int  

81:  

82:     cursor = get_bdd().cursor()  

83:  

84:     cursor.execute(  

85:         "update fruits set name = ?, kcal_100gr = ? where  

86:              id = ?",  

87:         (name, kcal, id)  

88:         )  

89:  

90:     get_bdd().commit()  

91:      

92:     return cursor.rowcount > 0  

93:  

94: def drop_fruit(id):  

95:     assert type(id)==int  

96:  

97:     cursor = get_bdd().cursor()  

98:  

99:     cursor.execute(  

100:         "delete from fruits where id = ?",  

101:         (id,) # tuple necessaire -> virgule  

102:         )  

103:  

104:     get_bdd().commit()  

105:      

106:     return cursor.rowcount

 

Une autre technique consiste à retourner une liste vide [ ] s’il n’y a pas de donnée, ce qui évite au code appelant de devoir faire un test sur None avant d’énumérer le contenu.

Le fichier views.py

Ce script contient la définition des différentes routes pour la prise en charge des requêtes. Les vues (views.py) s’appuient sur le modèle (models.py) pour collecter les données depuis la base de données. Les données récupérées depuis le modèle sont ensuite utilisées avec des templates Jinja pour produire le contenu HTML.

01: # coding: utf8  

02: from app import app  

03: from app.models import get_fruits, get_fruit, insert_fruit,  

04:      update_fruit, drop_fruit  

05: from flask import render_template, request, abort,  

06:      redirect, url_for  

07:  

08: def safe_cast( value, totype, default=None):  

09:     try:  

10:         return totype( value )  

11:     except Exception, e:  

12:         return default  

13:  

14: @app.route(’/’)  

15: def fruit_list():  

16:     fruits = get_fruits()  

17:     app.logger.debug( ’get_fruits(): %s’, fruits )  

18:     return render_template( ’fruit_list.html’, rows=fruits )  

19:  

20: @app.route(’/fruit-edit/<int:id>’, methods=[’GET’,’POST’] )  

21: @app.route(’/fruit-edit/new’, methods=[’GET’,’POST’] )  

22: def fruit_edit( id=-1 ):  

23:     """ Edition et sauvegarde d’un fruit """  

24:     if request.method == ’GET’:  

25:         if id == -1: # New record ?  

26:             nom = ’Nouveau’  

27:             kcal = 0  

28:             title = ’Nouveau fruit’  

29:         else:  

30:             fruit = get_fruit( id )  

31:             if not(fruit):  

32:                 raise Exception( ’Impossible de charger id  

33:                      %s’ % id )  

34:                  

35:             nom = fruit[1]  

36:             kcal = fruit[2]  

37:             title = ’Modifier %s’ % nom  

38:         return render_template( ’fruit_edit.html’, id=id,  

39:              nom=nom, kcal=kcal, title=title )  

40:  

41:     else: # C’est un POST  

42:         # recupérer les paramètres  

43:         nom = request.form[’nom’]  

44:         kcal = safe_cast( request.form[’kcal’], int, 0 )  

45:         id   = safe_cast( request.form[’id’], int, None )  

46:         if id == None:  

47:             raise Exception( ’Malformed ID dans le POST!’)  

48:  

49:         if id == -1: # Nouvel enregistrement ?  

50:             rowid = insert_fruit( nom, kcal )  

51:             app.logger.debug( ’Insertion fruit à id=%s’,  

52:                  rowid)  

53:         else: # C’est une modification (update)  

54:             update_fruit( id, nom, kcal )  

55:         return redirect( url_for(’fruit_list’) )  

56:  

57:  

58: @app.route(’/fruit-delete/<int:id>’)  

59: def fruit_delete( id ):  

60:     """ Effacer un enregistrement """  

61:     count = drop_fruit( safe_cast(id, int) )  

62:     app.logger.info( ’Effacer fruit %s pour id=%s’,  

63:          count, id )  

64:     return redirect( url_for(’fruit_list’) )

images/06RI30.pngimages/06RI30.png
 

Liste des fruits. Produit par la route « / »

images/06RI32.pngimages/06RI32.png
 

Édition d’une entrée existante

images/06RI33.pngimages/06RI33.png
 

Insertion d’une nouvelle entrée

 

Bien que la fonction fruit_edit() utilise id=-1 pour détecter la création d’un nouvel enregistrement, il n’est pas possible d’appeler l’URL /fruit-edit/-1 car -1 n’est pas reconnu comme un nombre entier ! En conséquence, l’URL /fruit-edit/-1 ne peut pas être prise en charge par une route.

Séquence de la modification

Le diagramme suivant résume la séquence des appels lors de la modification d’un fruit existant.

images/06RI34.pngimages/06RI34.png
 

Séquence d’appel lors de la modification d’un enregistrement (réalisé avec draw.io)

Séquence d’un ajout

Le diagramme suivant résume la séquence des appels lors de l’ajout d’un nouveau fruit.

Cette séquence est un cas particulier de la séquence de modification. Le comportement général est en effet identique avec quelques petites variantes.

images/06RI35.pngimages/06RI35.png
 

Séquence d’appel lors de l’ajout d’un enregistrement (réalisé avec draw.io)

Template fruit_list.html

Ce template Jinja est appelé par la fonction de traitement fruit_list() du fichier views.py.

Le template est appelé avec le paramètre rows correspondant à une liste de tuples, à savoir [ (id, nom, kcal_100gr), ... ].

Il permet de produire le contenu suivant :

images/06RI30.pngimages/06RI30.png
 

Liste des fruits produite avec le template fruit-list.html

Le template contient :

01: <!DOCTYPE HTML>  

02: <html>  

03:    <head>  

04:       <title>Fruit list</title>  

05:    </head>  

06:    <body>       

07:         <h1>Fruits</h1>  

08:         <table border="1">  

09:             <tr>  

10:                    <th>Nom</th>  

11:                    <th>KCal / 100gr</th>  

12:                    <th>Options</th>  

13:              </tr>  

14:             {% for el in rows %}  

15:                 <tr>  

16:                 <td>{{ el[1] }}</td>  

17:                 <td align="right">{{ el[2] }}</td>  

18:                 <td><small>  

19:                   <a href="{{ url_for(’fruit_edit’,  

20:                        id=el[0]) }}">Editer</a>  

21:                   <a href="{{ url_for(’fruit_delete’,  

22:                        id=el[0]) }}">Effacer</a>  

23:                   </small></td>  

24:                 </tr>  

25:             {% endfor %}  

26:             <tr><td align="right" colspan="3">  

27:               <small>  

28:               <a href="{{ url_for(’fruit_edit’)  

29:                    }}">Nouveau</a>  

30:               </small></td></tr>  

31:         </table>  

32:    </body>  

33: </html>

 

Comme cela a été détaillé dans les explications des vues (views.py), il n’est pas possible d’appeler l’URL /fruit-edit/-1 avec un id négatif.

Template fruit_edit.html

Ce template est appelé par la fonction de traitement fruit_edit() du fichier views.py.

Le template est appelé avec les paramètres :

Le template permet de produire les contenus suivants :

images/06RI32.pngimages/06RI32.png
 

Modification d’un enregistrement (id ≥ 0)

images/06RI33.pngimages/06RI33.png
 

Ajout d’un nouvel enregistrement (id = -1)

Le template contient :

01: <!DOCTYPE HTML>  

02: <html>  

03:    <head>  

04:       <title>{{ title }}</title>  

05:    </head>  

06:    <body>       

07:         <h1>{{ title }}</h1>  

08:         <form  

09: action="{{ url_for(’fruit_edit’,id=id if id>=0 else None) }}"  

10:        method="post">  

11:         <table border="1">     

12:             <tr>  

13:                 <td>Nom</td>  

14:                 <td><input type="text" name="nom"  

15:                     value="{{ nom }}"></td>  

16:             </tr>  

17:             <tr>  

18:                 <td>KCal / 100gr</td>  

19:                 <td><input type="text" name="kcal"  

20:                      value="{{ kcal }}"></td>  

21:             </tr>  

22:      

23:             <tr><td align="right" colspan="2">  

24:               <input type="hidden" name="id"  

25:                    value="{{ id }}">  

26:               <input type="submit" value="Envoyer" />  

27:               <input type="button"  

28:     onclick="location.href=’{{ url_for(’fruit_list’) }}’;"  

29:                    value="Abandonner" />  

30:                     

31:               </td></tr>  

32:         </table>  

33:         </form>  

34:    </body>  

35: </html>

 

Il faut prendre en charge l’appel avec un id ≥ 0 vers l’URL /fruit-edit/id tout comme l’appel avec id = -1 débouchant vers l’URL /fruit-edit/new (lorsque l’id est omis lors de l’appel d’url_for() ). Cette particularité est prise en charge par l’expression ternaire id if id>=0 else None qui retourne l’id si celui-ci est supérieur ou égal à zéro, sinon l’expression retournera None (ce qui sera le cas si id égale -1). Lors de l’appel d’url_for(), le paramètre id= communiqué recevra donc une valeur numérique ou None selon les circonstances.

 

L’extension Flask-WTF déjà évoquée plusieurs fois dans l’ouvrage permet de réaliser un contrôle de saisie et une validation côté client avant l’envoi du formulaire.

12. Ressources et documentations

Il existe de nombreuses informations sur Flask :

Templates Jinja

images/06RI36.pngimages/06RI36.png
 

Logo du sous-projet Jinja

Jinja est un puissant moteur de template utilisé par Flask pour produire du contenu mis en forme incluant les données collectées par l’application. La section consacrée à la gestion des vues et des routes a déjà présenté quelques exemples. Cette section va approfondir les concepts propres au moteur de template Jinja. Celui-ci est généralement utilisé pour produire du contenu HTML, mais il peut également produire du contenu XML, CSV, texte et autres.

Dans un template Jinja, le document contient des balises spéciales utilisant des accolades, interprétées et remplacées par le moteur de template en vue de produire le contenu final.

Jinja apporte des structures de contrôle de type if, else, for, les assignations, les filtres et les expressions à la génération de document.

Derrière ces fonctionnalités élémentaires, Jinja apporte également de puissants concepts comme l’héritage de documents, la gestion d’extension et l’inclusion.    

1. Exécution d’un template

La section précédente consacré à la gestion des routes et des vues indiquait que l’exécution d’un template depuis le code Python passe par la fonction render_template().

from flask import render_template 

... 

@app.route(’/’)  

def fruit_list():   

  return render_template( ’fruit_edit.html’, id=id,  

                          nom=nom, kcal=kcal, title=title )

La fonction render_template() reçoit le nom du fichier template à utiliser (ex. : fruit_edit.html) qui doit impérativement se trouver dans le sous-répertoire templates du projet. À noter que l’extension « .html » n’est qu’une convention.

La fonction render_template() peut également recevoir des paramètres nommés qui deviendront accessibles dans le template, ce qui est le cas des variables id, nom, kcal, title dans l’exemple ci-dessus.

2. Tester un template

Pouvoir tester les différents cas de figure peut s’avérer très enrichissant durant la phase d’apprentissage. Cette section présente plusieurs approches pour tester un template Jinja.

Parmi les différentes options, il existe :

1.

Créer une application Flask

2.

Tester avec serveur web Flask et string Python

3.

Tester en console avec string Python (SANS serveur Flask)

4.

Utiliser le projet Jinja Live Parser

a. Créer une application Flask

Cette option est la plus simple, mais aussi la plus contraignante à mettre en œuvre. En effet, il est nécessaire de créer la structure de répertoires, gérer les routes, les fichiers template, etc.

Pour

Contre

Lourdeur de mise en œuvre (ex. : tester un cas de figure élémentaire ou tester une fonctionnalité Jinja).

b. Test avec serveur web Flask et string Python

Abordée à plusieurs reprises dans les différents exemples dans le répertoire /python/flask-demos/, cette option permet de préparer une mini application Flask tenant dans un seul fichier tout en exploitant le moteur de rendu Flask.

Ci-dessous le contenu du fichier /python/flask-demos/flask-mini-app.py dans le dépôt GitHub du projet.

# coding: utf8  

from flask import Flask  

 

app = Flask( __name__ )  

 

template =  """<!DOCTYPE html>  

<html>  

<body>  

<h1>demo</h1>  

{{ name }}  

</body>  

</html>""".decode(’utf8’)  

 

@app.route(’/’ )  

def test():  

  nom=’demo’  

  lst=[1,3,8,12]  

  return app.jinja_env.from_string( template ).render( nom=nom,valeur=lst)  

 

app.run( debug=True, port=5000, host=’0.0.0.0’)

Pour

Contre

c. Test en console et string Python

Il est également possible d’utiliser le moteur de rendu de Jinja pour traiter un template Jinja stocké dans une chaîne de caractères et générer une chaîne de caractères affichée directement dans la console.

Cette approche est tellement concise qu’elle peut être utilisée dans une session Python interactive.

pi@pythonic:~ $ python  

Python 2.7.9 (default, Sep 17 2016, 20:26:04)  

[GCC 4.9.2] on linux2  

Type "help", "copyright", "credits" or "license" for more 

information.  

>>> from jinja2 import Template  

>>> tmp = """---{% for itm in lst %}+-+{{ itm }}{% endfor %}---"""  

>>> lst = [’Banane’, ’Orange’, ’Fraise’]  

>>> Template( tmp ).render( lst=lst )  

u’---+-+Banane+-+Orange+-+Fraise---’

Pour :

Contre :

d. Utiliser le projet Jinja Live Parser

Le projet Jinja Live Parser, disponible sur le dépôt GitHub, est un outil convivial qui permet de tester le moteur de template Jinja depuis une interface web.

https://github.com/qn7o/jinja2-live-parser

Une fois téléchargé et les prérequis installés, Jinja Live Parser peut être démarré avec python parser.py.

$ git clone https://github.com/qn7o/jinja2-live-parser.git 

$ pip install -r requirements.txt 

$ python parser.py

Démarré comme n’importe quelle autre application Flask, l’interface de Jinja Live Parser est accessible depuis un navigateur Internet sur l’adresse http://127.0.0.1:5000/.

L’interface se présente comme suit :

images/06RI44.pngimages/06RI44.png
 

La section Template permet de saisir le template Jinja à tester.

La section Render permet de voir le résultat du moteur de rendu une fois le bouton Convert pressé.

La section Settings permet de configurer les options du moteur de rendu. Il propose également le bouton Convert qui lance l’interprétation du template Jinja.

La section Values permet de saisir un dictionnaire de variables au format JSON. Chaque clé (ex. : name, test) représente le nom d’une variable Jinja accessible dans le template.

Pour :

Contre :

3. Évaluation des balises

La base du fonctionnement de Jinja est l’interprétation de balises utilisant des accolades. Il y a trois types de balises auxquels se joint une option permettant de saisir des instructions déclaratives.

Espace obligatoire

Le corps de la balise (ce qu’il y a entre les accolades) doit toujours être séparé par un espace des accolades. Ainsi, la balise {{ nom }} est correcte, alors que {{nom}} ne l’est pas ! De même, la balise {% if user.genre==’M’ %} est correcte, alors que {%if user.genre==’M’%} ne l’est pas !

a. {{ … }} : évaluation d’expression

La double accolade permet d’évaluer une variable ou une expression Jinja dont le résultat est inclus dans le document de sortie.

b. {% … %} : instructions de contrôle de flux

Jinja prévoit des instructions pour réaliser des structures de contrôle. Ces structures de contrôle permettent d’altérer le flux d’exécution du template et, par conséquent, d’influer sur le contenu généré par le template.

Parmi les structures de contrôle, il y a le branchement conditionnel réalisé avec les balises {% if %} et {% endif %} ainsi que la boucle d’itération avec {% for %} et {% endfor %}. Ces structures seront étudiées en temps voulu.

Dans tous les cas, l’évaluation des instructions de contrôle flux passent pas une ou plusieurs balises {% ... %}.

Contrôle de flux, indentation et espaces additionnels

Pour faciliter la lecture des templates, il est assez courant d’indenter leur contenu et les différentes instructions de contrôle de flux pour rendre le code plus facile à lire.

La conséquence directe de ces indentations est de retrouver des espaces additionnels dans le flux de sortie. Si cela n’a théoriquement pas d’importance en ce qui concerne la structure HTML, l’inclusion d’espaces aura plus d’impact sur le contenu textuel affiché. C’est la raison pour laquelle Jinja prévoit également des mécanismes de contrôle des espaces dans les balises Jinja. Ce point est également détaillé ultérieurement.

c. {# … #} : insertion de commentaire

Jinja prévoit une balise de commentaire qui peut s’étendre sur plusieurs lignes. Les commentaires peuvent être utilisés pour :

Tout ce qui se trouve entre l’ouverture de balise {# et le symbole de fermeture de balise #} sera simplement ignoré par le moteur de template.  

d. # …  : ligne d’instruction

À titre informatif, Jinja prévoit également l’activation d’une option line statement permettant de traiter des instructions de contrôle de flux sur des lignes commençant par le caractère de préfixe # à la place des balises {% ... %}.

Lorsque l’option line statement est active, un code utilisant une instruction de branchement if :

<h1> 

 # if user.genre.upper() == ’M’ 

 Monsieur 

 # else 

 Madame 

 # endif 

 {{ nom }} 

</h1>

Fonctionne de façon identique à la structure utilisant des balises Jinja :

 <h1> 

 {% if user.genre.upper() == ’M’ %} 

 Monsieur 

 {% else %} 

 Madame 

 {% endif %} 

 {{ nom }} 

</h1>

Cependant, l’utilisation de line statement n’est pas une approche supportée par la communauté des développeurs Flask (cf. Forums Jinja).

4. Variables et expressions

Les templates Jinja utilisent des balises d’évaluation d’expression {{ ... }} pour inclure des variables et des expressions dans le flux généré par le template.

Par conséquent, si la variable nom est passée en paramètre à render_template() alors l’utilisation de {{ nom }} dans le template permet d’insérer la valeur de la variable nom dans le document produit.

Par exemple :

from flask import render_template 

... 

@app.route(’/’)  

def afficher_variable(): 

  nom = ’tux’ 

  nom2 = ’le pingouin’ 

  return render_template( ’voiture.html’, nom=nom, prenom=nom2 )

alors il devient possible d’afficher le contenu de la variable nom dans le template HTML en utilisant :

...  

<h1>Nom</h1><br /> 

{{ nom }}, {{ prenom }} 

...

 

L’exemple indique comment changer le nom d’un paramètre en appelant la fonction render_template(). En effet, avec la syntaxe prenom=nom2 la variable nom2 du script Python sera accessible avec le nom de variable prenom dans le template. C’est également pour cette raison que l’appel de render_template() utilise le formalisme nom=nom pour créer une variable de template nom nommée identiquement à la variable nom du script Python.

Étant donné qu’il s’agit de l’évaluation d’expressions Python (une variable étant aussi une expression élémentaire), il est possible de passer un objet au template et d’accéder aux différents attributs de l’objet depuis le template.

Par exemple :

from flask import render_template 

... 

@app.route(’/’)  

def afficher_voiture(): 

  voiture = get_voiture( id = 15 ) 

  # voiture.modele.nom -> Beta Juliette   

  return render_template( ’voiture.html’, v=voiture )

Alors il devient possible d’afficher le nom du modèle dans le template HTML en utilisant :

...  

<h1>{{ v.modele.nom }}</h1><br /> 

Année de fabrication : {{ v.modele.fabrication.annee_debut }}  

...

Les expressions permettent également d’utiliser des fonctions (et des méthodes sur un objet), des opérations mathématiques et l’évaluation d’expressions ternaires comme valeur_1 if test==True else valeur_2.

Par exemple :

from flask import render_template 

... 

@app.route(’/’)  

def combien_de_fruits(): 

  fruits = [’banane’, ’orange’ ] 

  # fruit[0] -> banane   

  return render_template( ’compter_fruit.html’, fruits=fruits )

Il devient alors possible d’afficher le nombre d’éléments de la liste avec la fonction Python standard len() :

...  

<h1>Info Fruit</h1><br /> 

La liste contient {{ len(fruits) }} fruits. 

Premier Fruit est : {{ fruits[0] }}  

...

a. Variables spéciales

Lorsque Flask effectue le traitement d’une requête, plusieurs objets sont accessibles dans le code Python (ex. : g, session, request et request.cookies). Lorsque Flask demande le rendu d’un template Jinja, plusieurs variables sont automatiquement communiquées au template dont :

Leur utilisation dans des expressions est similaire à celle effectuée en Python.

L’application Flask /python/flask-demos/special-var-app/ disponible sur le dépôt GitHub de l’ouvrage démontre l’accès aux différentes variables disponibles depuis le template Jinja. Ce code source est à consulter pour en savoir plus sur le sujet.

images/06RI45.pngimages/06RI45.png
 

Résultat de l’application Flask special-var-app

 

Cette application Flask capture toutes les URL et fournit un rendu des variables spéciales à l’aide du template specialvar.html.

b. Séquence d’échappement

Étant donné que Jinja utilise des doubles accolades pour l’évaluation d’expressions, faire apparaître une double accolade dans le document nécessite l’utilisation d’une astuce technique appelée séquence d’échappement.

Pour afficher une double accolade, il faut évaluer l’une des expressions suivantes en fonction du résultat souhaité :

<h1>Afficher une double accolade ouverte</h1><br /> 

{{ ’{{’ }} 

<h1>Afficher une double accolade fermée</h1><br /> 

{{ ’}}’ }}

Une autre option consiste à utiliser un bloc d’instruction raw :

{% raw %} afficher les {{ et les }} sans interprétation de balise {% endraw %}

c. Assignation

Bien qu’utilisée moins fréquemment, Jinja prévoit une balise d’assignation {% set %}. Cette balise permet d’assigner une valeur à une variable dans un template Jinja.

L’assignation supporte de multiples formats de données et peut également traiter de multiples paramètres.

{% set fruits = [ ’Banane’, ’Orange’, ’Framboise’ ] %}  

{% set couleurs = [ (’Rouge’, ’#FF0000’), (’Vert’, ’#00FF00’), (’Bleu’, ’#0000FF’) ] %} 

{% set R,G,B = 10,60,80 %}  

{% set A,B,C = (15,’Demo’,R) %}  

{% set nom, prenom = get_user_names( user_id ) %}

5. Branchement

L’instruction de contrôle de flux if permet de réaliser des branchements à partir des balises {% if expression %} et {% endif %}. Ces balises permettent de traiter le contenu situé entre elles uniquement si l’expression retourne un résultat pouvant être évalué comme True.

Comme pour de nombreux autres langages de programmation, le contrôle de flux par branchement prévoit les balises complémentaires {% else %} et {% elif expression %}.

L’exemple suivant teste le genre de l’utilisateur pour afficher un libellé Monsieur, Madame, etc. devant le nom de l’utilisateur. L’objet user expose un attribut genre qui contient une chaîne de caractères.  

<h1> 

 {% if user.genre.upper() == ’F’ %} 

     Madame 

 {% elif user.genre.upper() == ’M’ %} 

     Monsieur 

 {% elif user.genre.upper() == ’MS’ %} 

     Mademoiselle 

 {% else %} 

    (genre {{user.genre}} inconnu)  

 {% endif %} 

 {{ nom }} 

</h1>

Qui produit :

 

Étant donné que l’attribut genre est une chaîne de caractères Python (string), il est possible d’appeler les méthodes qu’elle expose. Par conséquent, genre.user.upper() retourne la valeur du genre en majuscule.

6. Itération

Jinja prévoit une instruction de contrôle de flux {% for x in collection %}{% endfor %} permettant une itération sur le contenu d’une collection en répétant une partie du template.

L’exemple suivant parcourt la liste fruits ( fruits=[’Banane’,’Mangue’, ’Ananas’] ) pour produire une liste à puce.

<h1>Liste des fruits</h1>  

<ul>  

 {% for fruit in fruits %}  

 <li>{{ fruit }}</li>  

 {% endfor %}  

</ul>

Il est également possible de parcourir des collections d’éléments plus complexes comme un dictionnaire ou une liste de tuple.

Par exemple, pour un dictionnaire dico défini par le code Python :

dico = { "0": "zéro", "1": "un", "2": "deux", "3": "trois", "4": "quatre",

"5": "cinq", "6": "six", "7": "sept", "8": "huit", "9": "neuf" } 

x

Il est possible d’écrire les templates suivants :

<h1>Les chiffres</h1><br /> 

<ul> 

 {% for cle, valeur in dico.iteritems() %} 

 <li>{{ cle }} = {{ valeur }}</li> 

 {% endfor %}  

</ul>

Pour le dictionnaire dico, la liste à bulle reprend l’énumération :

. 1 = un 

. 0 = zéro 

. 3 = trois 

. 2 = deux 

. 5 = cinq 

. 4 = quatre 

. 7 = sept 

. 6 = six 

. 9 = neuf 

. 8 = huit

 

Les dictionnaires ne sont pas des éléments triés. Pour obtenir une liste triée, il faut utiliser le filtre sort dans la balise for. Ex. : {% for cle, valeur in dico.iteritems() | sort %}.

Filtrage des éléments

Tout comme cela est possible avec la List Comprehension de Python, il est possible de réduire l’ensemble des données de la boucle for en appliquant une condition de test.

Si la condition de test est évaluée à true pour l’élément, alors l’élément passe dans l’itération sinon il est ignoré.

Dans l’exemple précédent, la boucle for est modifiée pour filtrer les éléments supérieurs à 5.

<h1>Les chiffres</h1><br /> 

<ul> 

 {% for cle, valeur in dico.iteritems() if cle|int > 5 %} 

 <li>{{ cle }} = {{ valeur }}</li> 

 {% endfor %}  

</ul>

 

Étant donné que le dictionnaire contient la valeur numérique sous forme de chaîne de caractères, il convient de transformer celle-ci en entier avant de la comparer à la valeur 5. En effet, la comparaison d’une chaîne de caractères > 5 est toujours vraie. La transformation vers un entier se fait à l’aide du filtre Jinja int d’où la notation cle | int. Les filtres Jinja sont abordés plus loin dans le chapitre.

Cette fois, le résultat retourné est :

7 = sept 

6 = six 

9 = neuf 

8 = huit

Variables spéciales

À l’intérieur d’une boucle for, le moteur de template Jinja met à disposition une série de variables spéciales. Ces variables sont utilisables comme n’importe quelle autre variable Jinja.

Ces variables spéciales peuvent être utilisées pour altérer le flux de sortie en fonction de conditions spécifiques. Grâce à ces variables, il est possible d’alterner la couleur des lignes d’un tableau une ligne sur deux.

Variable

Description

loop.index

Numéro d’itération de la boucle (commence à 1). Soit une séquence 1, 2, 3, 4, 5...

loop.index0

Numéro d’itération de la boucle (commence à 0). Soit une séquence 0, 1, 2, 3, 4...

loop.revindex

Nombre d’itérations restant jusqu’à la fin de la boucle (correspondant à loop.index). Soit une séquence 5, 4, 3, 2, 1.

loop.revindex0

Nombre d’itérations restant jusqu’à la fin de la boucle (correspondant à loop.index0). Soit une séquence 4, 3, 2, 1, 0.

loop.first

True lors de la première itération.

loop.last

True lors de la dernière itération.

loop.length

Le nombre d’éléments dans la séquence.

loop.cycle

Fonction utilitaire permettant de cycliser une valeur parmi différents éléments d’une séquence. À chaque nouvelle itération de la boucle for, la valeur suivante est extraite de la séquence. Voir explications ci-dessous.

loop.depth

Indique la profondeur de récursivité de la boucle for. Démarre au niveau 1.

loop.depth0

Indique la profondeur de récursivité de la boucle for. Démarre au niveau 0.

loop.previtem

L’élément précédent de l’itération. Indéfini s’il s’agit de la première itération.

loop.nextitem

L’élément suivant de l’itération. Indéfini s’il s’agit de la dernière itération.

loop.changed(*val)

True si la valeur en paramètre a changé depuis le dernier appel (ou n’a jamais été appelée).

La fonction loop.change (*val ) permet de changer la couleur des lignes d’un tableau uniquement si une valeur clé change d’une ligne à l’autre.

La variable loop.cycle permet de créer un cycle récurrent de valeurs, basé sur une séquence, pour chaque itération de la boucle for.
 

L’exemple suivant fait un rendu des lettres du mot Arc-en-Ciel (code Python lst = list( ’Arc-en-Ciel’ ) ) en utilisant un cycle de couleurs.

<strong>  

{# lst obtenu avec  

{% for element in lst %}  

 <font color="{{ loop.cycle( "Tomato", "Orange", "DodgerBlue",  

"MediumSeaGreen", "Gray", "SlateBlue", "Violet", "LightGray" ) }}">

{{ element }}</font>  

{% endfor %}  

</strong>

Ce qui produit le résultat (en couleur) suivant lorsque le template Jinja produit la page HTML.

images/06RI37.pngimages/06RI37.png
 

Résultat de loop.cycle()

Altérer le comportement itératif

Il est possible de modifier le comportement de la boucle for à l’aide des balises {% continue %} ou {% break %}.

La balise {% continue %} permet de démarrer immédiatement la prochaine itération de la boucle {% for %}.

La balise {% break %} permet d’interrompre immédiatement la boucle {% for %} et de poursuivre le traitement du template juste après la balise {% endfor %}.

Plus d’informations

Il reste de nombreuses fonctionnalités à découvrir sur l’itération (détection de parité, l’itération récursive, etc.). Un complément d’information est disponible sur la page suivante : http://jinja.pocoo.org/docs/2.10/templates/#list-of-control-structures

7. Les macros

Les macros Jinja se définissent un peu comme une fonction pour Python sauf qu’elles s’appliquent au template Jinja. Comme les fonctions, les macros acceptent des paramètres qui peuvent ensuite être utilisés pour produire du contenu.

Une macro se définit avec les balises {% macro nom_de_la_macro(param) %} et {% endmacro %} et contient les éléments à générer dans le flux de sortie lorsqu’elle est appelée. 

Une macro simple

Une macro s’appelle avec les balises {% call nom_de_la_macro(param) %} et sa balise complémentaire {% endcall %}, cela signifie qu’il est possible d’inclure des éléments de flux entre ces deux balises, éléments que la macro a le loisir de « récupérer ». Ce contenu est accessible en évaluant la balise {{ caller() }} depuis la macro.

L’exemple suivant définit une macro qui dessine une boîte avec un élément <div>. La macro reçoit un paramètre titre et un paramètre color (pour la couleur du bord).

Pour finir, la macro est appelée depuis le template Jinja.

{% macro faire_boite(title, color=’green’) -%}  

   <div style="border-color:{{ color }}; border-width: 3px; 

border-style: solid;">  

       <h2>{{ title }}</h2>  

       <p>{{ caller() }}</p>  

   </div>  

{%- endmacro %}  

 

<!DOCTYPE html>  

<meta charset="UTF-8">  

<html>  

<body>  

<h1>Démo macro Jinja</h1><br />  

{% call faire_boite(’Bonjour à tous’) %} 

   Ceci est un exemple de définition et d’appel  

   de macro. Il est même possible d’évaluer des  

   variables dans le corps de l’appelant... commme  

   par exemple "{{ nom }}". 

{% endcall %}  

<br />  

{% call faire_boite(’Interessant’, color=’red’) %} 

   Bien entendu, les macro peuvent servir à  

   de nombreuses autres choses! 

{% endcall %}  

</body>  

</html>

Ce qui produit le résultat suivant :

images/06RI38.pngimages/06RI38.png
 

Démonstration de la macro faire_boite

L’exemple met en évidence le fait que le bloc appelant peut utiliser des balises pour évaluer des variables (voir {{ nom }} ) dans le bloc appelant la macro.

Bloc appelant avec paramètre

Une macro a également la possibilité d’évaluer le bloc appelant {{ caller() }} en spécifiant un paramètre {{ caller(un_parametre) }}, paramètre qui peut être exploité dans le bloc appelant.

Par exemple, la macro suivante liste les utilisateurs en permettant à l’appelant de préciser des informations complémentaires pour chacun des utilisateurs.

{% macro lister_utilisateurs( liste_util ) %}  

   <ul>  

   {% for utilisateur in liste_util %}  

       <li><p>{{ utilisateur.login }} - {{ utilisateur.nom }}</p> 

         {# Evaluer le bloc appelant en passant #} 

         {# l’utilisateur courant en paramètre  #}   

         {{ caller(utilisateur) }} 

       </li>  

   {% endfor %}  

   </ul>  

{% endmacro %}

Le bloc appelant la macro lister_utilisateurs doit alors préciser un nom de paramètre qu’il recevra lors de l’appel de {{ caller( utilisateur ) }} dans la macro.

La syntaxe de balise {{ call }} précise donc le paramètre utilis à recevoir {% call(utilis) lister_utilisateurs(liste_utilisateurs) %} !

Le paramètre utilis reçu lors de l’évaluation du bloc {% call %} / {% endcall %} sera celui communiqué par l’évaluation de {{ caller(utilisateur) }} dans la macro.

<h1>Liste des utilisateurs</h1>  

{% call(utilis) lister_utilisateurs(liste_utilisateurs) %}  

   <p>Surnom: {{ utilis.surnom }}<br />  

   Hobby: {{ utilis.hobbies.text }}</p>  

{% endcall %}

Voir également la documentation Jinja concernant la définition de macro : http://jinja.pocoo.org/docs/2.10/templates/#macros

8. Contrôle des espaces

Il est courant d’indenter le contenu d’un template pour faciliter la lecture de son contenu. Cependant, ces indentations introduisent des espaces additionnels dans le flux de sortie.

Lorsque le template produit un contenu HTML, ces espaces peuvent parfois perturber l’interprétation du code HTML par le navigateur et le rendu des pages HTML. Pour éviter ce type de problème, Jinja introduit un mécanisme de contrôle des espaces dans le flux de sortie.

Dans son comportement par défaut, Jinja :

Ainsi, le template suivant :

<h1>Démo espace</h1><br /> 

 

{{ nom }} 

 

Info text

produit le résultat :

<h1>Démo espace</h1><br•/> 

 

Dominique 

 

Info text

Jinja prévoit l’addition du signe « - » à l’avant et/ou l’arrière d’une balise Jinja {% ... %} ou {{ ... }} pour éliminer les espaces.

Éliminer à l’arrière

Placé à l’arrière d’une balise Jinja, le « - » enlève les espaces (et retours à la ligne) jusqu’au premier caractère suivant la balise. Par conséquent, le template suivant :

<h1>Démo espace</h1><br /> 

 

{{ nom -}} 

 

Info text

produit le résultat HTML suivant où il est clairement visible que les espaces ont été enlevés derrière la balise {{ nom }} :

<h1>Liste des utilisateurs</h1><br /> 

 

DominiqueInfo text

Éliminer à l’avant

Placé à l’avant d’une balise Jinja, le « - » enlève les espaces (et retours à la ligne) jusqu’au premier caractère précédent la balise. Par conséquent, le template suivant :

<h1>Démo espace</h1><br /> 

 

{{- nom }} 

 

Info text

produit le résultat HTML suivant où il est clairement visible que les espaces ont été enlevés à l’avant de la balise {{ nom }} :

<h1>Liste des utilisateurs</h1><br />Dominique 

 

Info text

 

Étant donné que le contrôle des espaces (et des retours à la ligne) est placé sur les balises Jinja, ceux-ci ne seront pas supprimés entre les balises HTML.

Exemple

Dans l’exemple suivant, la séquence seq = [’A’, ’B’, ’C’, ’D’, ’E’, ’F’] peut être affichée avec le template :

{% for el in seq %}  

 {{ el }}  

{% endfor %}

Produisant le résultat suivant s’il n’y a pas de suppression d’espace.

 

 

 

 

 

 

En supprimant les espaces, à l’aide du template légèrement modifié :

{% for el in seq -%} 

 {{ el }} 

{%- endfor %}

Le résultat est, cette fois, entièrement différent !

ABCDEF

Voir http://jinja.pocoo.org/docs/2.10/templates/#whitespace-control.

9. Filtres Jinja

Les filtres sont utilisés dans les balises d’expressions {{ ... }} et permettent d’effectuer un traitement sur l’expression évaluée avant de l’inclure dans le flux de sortie. Un filtre est donc une fonction qui reçoit un flux, le transforme et sort un flux transformé.

Resultat = le_filtre( valeur_de_l_expression )

Un exemple typique de filtre est la suppression des espaces excédentaires, la mise en majuscule (uppercase), le formatage de titre (première lettre de chaque mot en majuscule), etc.

Le filtre est mentionné après l’expression dont il est séparé par un caractère pipe « | ».

L’exemple suivant insère le nom inversé et mis en majuscules dans le flux de sortie :

{{ nom | reverse | upper }}

Si nom contenait la valeur Tux, le résultat inséré dans le flux de sortie serait XUT. S’il fallait utiliser des fonctions, l’exemple correspondrait à upper( reverse( nom ) ), la notation utilisant le filtre reste cependant plus lisible.

Jinja prend également en charge des filtres paramétrables. Il s’agit alors de fonctions filtres avec des paramètres complémentaires.

Resultat = le_filtre( valeur_de_l_expression, paramètre1=None, Paramètre2=None, ... )

Dans l’exemple suivant, le filtre replace va remplacer tous les « ux » par « uxedo » avant de produire un résultat en capital :

{{ nom | replace(’ux’,’uxedo’) | upper }}

Ce qui produit le résultat « TUXEDO-SUR-PI » pour une variable nom contenant « Tux-sur-Pi ». 

 

Étant donné que les filtres utilisent des fonctions Python, il est également possible de nommer les paramètres de la fonction filtre lors de l’appel du filtre. L’exemple précédent réécrit : {{ nom | replace(old=’ux’, new=’uxedo’) | upper }}.

Pour achever cette introduction, il est important de souligner que le système de filtre ne s’applique pas uniquement à du contenu de type texte. Les filtres peuvent également traiter des listes d’objets, des attributs, réaliser du transtypage, des calculs d’arrondis, etc.

La liste ci-dessous reprend quelques-uns des filtres les plus intéressants, regroupés par fonctionnalité logique.

Filtre

Prototype et description

Formatage de chaîne de caractères

capitalize

capitalize(s)

Le premier caractère passe en majuscule, tous les autres en minuscule.

center

center(value, width=80)

Centre une valeur dans un champ d’une valeur donnée.

default

default(value, default_value=u’’, boolean=False)

Si la valeur value est indéfinie, alors la fonction retourne la valeur par défaut default_value sinon c’est la valeur value qui est retournée.

{{ ma_variable|default(’Oups ! Indéfini’) }}

Le paramètre boolean permet d’indiquer que la valeur value doit être évaluée en booléen. Si le résultat booléen est False alors la valeur par défaut default_value est retournée. Cela permet de tester une chaîne vide comme False.

{{ ’’|default(’la chaine est vide’, True) }}

lower

lower(s)

Convertit une valeur en minuscule.

upper

upper(s)

Convertit une valeur en majuscule.

trim

trim(value)

Enlève les espaces à l’avant et à l’arrière d’une chaîne de caractères.

format

format(value, *args, **kwargs)

Applique le formatage d’un ou plusieurs arguments en utilisant la valeur value comme chaîne de formatage.

{{ "%s - %s"|format("Valeur", "trois") }}

Ce qui produit le résultat « Valeur - trois », équivalent du code Python "%s - %s" %("Valeur", "trois").

replace

replace(value, old, new, count=None)

Modifie la chaîne de caractères value pour remplacer toutes les occurrences de old par la valeur de new. Le paramètre count permet d’indiquer le nombre d’occurrences à remplacer.

{{ "Bonjour toi"|replace("Bonjour", "Au revoir") }}

Ce qui produit le résultat « Au revoir toi ».

escape

escape(value)

Convertit les caractères &, <, >, ‘ et ” contenus dans value en leurs entités HTML respectives. La fonction escape est utilisée pour inclure du texte qui utiliserait de tels caractères.

{{ "le tag <br />" | escape }} produit le résultat "le tag &lt;br /&gt;".

urlencode

urlencode(value)

Convertit le contenu d’une URL en utilisant les séquences d’échappement (encodée en UTF-8). Cette fonction accepte string et dictionnaire en paramètre.

urlize

urlize(value, trim_url_limit=None, nofollow=False, target=None, rel=None)

Convertit une URL en texte cliquable (avec une balise HTML <a>). Le filtre peut recevoir un paramètre additionnel pour raccourcir le texte associé à l’URL. Le paramètre target permet de définir une destination pour le lien.

{{ une_url_texte|urlize(30, target=’_blank’) }}

Lien raccourci à 30 caractères et spécifie l’attribut target pour le lien généré afin d’ouvrir un nouvel onglet dans le navigateur.

striptags

striptags(value)

Enlève les tags SGML/XML et remplace les espaces adjacents par un espace unique.

Typage et traitement mathématique

string

string(object)

Convertit un objet en chaîne de caractères unicode si ce n’est pas déjà le cas.

float

float(value, default=0.0)

Convertit la valeur value en un nombre à virgule flottante. Le paramètre default permet d’indiquer la valeur à retourner si la conversion échoue.

int

int(value, default=0, base=10)

Convertit une valeur value en entier. Le paramètre default permet de modifier la valeur retournée lorsque la conversion échoue (donc 0).

Le paramètre base permet de mentionner une autre base pour la conversion (2 pour binaire, 8 pour octal, 16 pour hexadécimale). Dans ce cas, le paramètre default peut s’exprimer avec des préfixes 0b (binaire), 0o (octal) et 0x (hexadécimal).

round

round(value, precision=0, method=’common’)

Arrondit la valeur value en utilisant la précision mentionnée (nombre de décimales). Le paramètre method permet de définir la méthode d’arrondi à utiliser :

  • •.’common’ : arrondit à la hausse ou à la baisse en fonction de la valeur décimale. Méthode d’arrondi par défaut du filtre round. 

  • •.’ceil’ : arrondit uniquement à la hausse. 

  • •.’floor’ : arrondit uniquement à la baisse. 

{{ 12.51|round }}

Produit la valeur 13.0

{{ 12.51|round(1, ’floor’) }}

Produit le résultat 12.5 (arrondi vers le bas avec une décimale).

Note : Le filtre round produit toujours un nombre en virgule flottante, même avec une précision de 0. Si le résultat doit être un entier, il faut alors filtrer le résultat avec int. Ex. : {{ 12.51 | round( 0, ’floor’ ) | int }} qui lui produira 13.

abs

abs(value)

Retourne la valeur absolue de la valeur value.

Traitement de collection

list

list(value)

Convertit la valeur value en liste. Si value est une chaîne de caractères, alors le filtre list retourne une liste de caractères.

first

first(seq)

Retourne le premier élément d’une séquence (ou liste).

last

last(seq)

Retourne le dernier élément d’une séquence (ou liste).

max

max(value, case_sensitive=False, attribute=None)

Retourne la valeur maximale existant dans une séquence. Ce filtre peut traiter des valeurs entières ou des chaînes de caractères.

{{ [1, 2, 3]|max }}

Retourne la valeur 3.

Le paramètre case_sensitive (False par défaut) permet de traiter les chaînes de caractères en fonction de leur casse.

Le paramètre attribute permet d’extraire la valeur d’un attribut dans le cas où value est une collection d’objets.

min

min(value, case_sensitive=False, attribute=None)

Similaire au filtre max mais retourne la valeur minimale.

length

length(objet) ou count(objet)

Retourne le nombre d’éléments dans une séquence (ou liste).

join

join(seq, d=u’’, attribute=None)

Retourne une chaîne de caractères concaténant les éléments de la séquence (ou la liste) avec un séparateur d inséré entre chaque valeur. Par défaut, le séparateur est une chaîne de caractères vide. Ex. :

{{ [10, 22, 13] | join(’~’) }}

Ce qui produit le résultat « 10~22~13 ».

Comme pour les filtres min et max, le paramètre attribut permet d’extraire la valeur d’un attribut lorsque value est une séquence d’objets. Ex. :

{{ utilisateurs | join( ’, ’ , attribute=’login’) }}

slice

slice(seq, slices, fill_with=None)

Découpe une liste (ou itérateur) pour créer une liste de listes où chaque sous-liste contient slices éléments.  

Le filtre slice est pratique, par exemple, pour découper une longue liste d’éléments pour réaliser un affichage des éléments en colonnes. Cela permet de réaliser un affichage type « navigateur de fichiers ».

sort

sort(value, reverse=False, case_sensitive= False, attribute=None)

Réaliser un tri ascendant sur une séquence (ou une liste) communiquée dans le paramètre value. Le paramètre reverse permet d’obtenir un tri descendant s’il est placé à True.

Si la séquence contient des chaînes de caractères, alors le paramètre case_sensitive permet de réaliser un tri sensible à la case.

Pour terminer, le paramètre attribute permet d’extraire la valeur de tri depuis un attribut particulier lorsque la séquence contient des objets.

tojson

tojson(value, indent=None)

Permet de transformer une structure en format JSON, ce qui permet d’injecter des données dans un tag <script>. Ce filtre utilise la fonction dumps() du module Python json.

Autres

safe

safe(value)

Marque la valeur value comme étant ’sûre’. Dans un environnement utilisant l’échappement automatique, cette valeur ne sera plus traitée pour transformer certains caractères en entité HTML.

pprint

pprint(value, verbose=False)

Effectue un Pretty Print d’une variable, d’un objet ou d’une structure. Le Pretty Print utilise un format de rendu plus agréable à lire, ce qui facilite les opérations de débogage.

Jinja inclut d’autres filtres dont la documentation est disponible sur la page officielle de Jinja :
http://jinja.pocoo.org/docs/2.10/templates/#builtin-filters
 

10. Inclusion de template

La balise {% include ’un_template.html’ %} permet d’inclure un template en lieu et place de la balise.

Par défaut, le template inclus peut accéder aux variables du contexte Jinja, ce qui permet de personnaliser le contenu produit par les templates inclus.

L’exemple suivant inclut deux templates page_header.html et page_footer.html et qui produisent respectivement le rendu de l’en-tête (titre page inclus) et du pied de page.

Le projet se présente comme suit :

include_app  

├── app  

│   ├── __init__.py  

│   ├── static  

│   ├── templates  

│   │   ├── main.html  

│   │   ├── page_footer.html  

│   │   └── page_header.html  

│   └── views.py 

└── runapp.py

Il s’agit d’une structure d’application Flask comme décrite précédemment, le fichier runapp.py permettant de démarrer facilement l’application.

Le fichier views.py, ci-dessous, ne prend en charge que la route racine, le but est d’appeler le moteur de template pour réaliser un rendu de main.html. À noter que le template invoqué reçoit une variable titre.

# coding: utf8  

from app import app  

from flask import render_template  

 

@app.route(’/’)  

def page_pincipale( ):  

  return render_template( ’main.html’,  

           titre=’Bienvenu sur la page principale’ )

Le fichier main.html construit le rendu du corps de page sans oublier d’inclure le template d’en-tête page_header.html et de pied page_footer.html.

{% include ’page_header.html’ %} 

<p>Ceci est un exemple d’inclusion de template Jinja.<p>  

<p>Le template "page_header.html" prend en charge le rendu jusqu’à la balise 

&lt;body&gt; et &lt;h1&gt; incluse.</p>  

<p>Le template "page_footer.html" prend la fin de la page html à partir du tag 

en &lt;/body&gt;</p>  

<p>De surcroît, cette exemple démontre que le template inclus peut évaluer les 

variables Jinja comme la balise {{’{{’}} titre {{’}}’}} évaluée à

"{{ titre|default("") }}"</p> 

{% include ’page_footer.html’ %}

Les fichiers page_header.html et page_header.html sont respectivement constitués comme suit :

<!DOCTYPE HTML>  

<html>  

  <head>  

     <title>{{ titre | default( ’-sans titre-’) }}</title>  

  </head>  

  <body>  

       <h1>{{ titre | default( ’-sans titre-’) }} </h1>

 

L’utilisation du filtre default permet d’éviter un message d’erreur si la variable titre est manquante lors de l’appel du template.

   </body>  

</html>

Cet exemple produit le résultat suivant :

images/06RI39.pngimages/06RI39.png
 

Exemple d’inclusion de template

Options

Notez que {% include %} prévoit l’option « ignore missing » qui ne produit pas d’erreur et ignore simplement la balise {% include ’un_template.html’ %} si un_template.html est manquant.

Les options « with context » et « without context » permettent d’inclure ou d’exclure le contexte (les variables du template appelant) lors de l’inclusion avec la balise {% include %}.

Ci-dessous toutes les syntaxes include disponibles :

{% include ’page_header.html’ %}  

{% include ’page_header.html’ ignore missing %}  

{% include ’page_header.html’ ignore missing without context %}  

{% include ’page_header.html’ ignore missing with context %}

11. Importer des macros

La balise {% import %} montre un fonctionnement similaire à l’instruction import de Python. Le plus souvent, cette balise permet d’importer des fichiers contenant des macros devant être utilisées dans plusieurs templates.

L’inconvénient des fichiers importés, c’est qu’ils n’ont pas accès aux variables du contexte local, mais uniquement au contexte global.

Par contre, les fichiers importés sont chargés en cache, ce qui permet d’améliorer significativement les performances.

Dans l’exemple suivant, le fichier /templates/forms.macro contient différentes macros pour générer des parties de formulaire HTML.

{% macro input(name, value=’’, type=’text’) -%}  

   <input type="{{ type }}" value="{{ value|e }}" name="{{ name }}">  

{%- endmacro %}  

 

{% macro submit(name, text=’submit’) -%}  

   <button type="submit" name="{{ name }}" value="{{ text }}">  

{%- endmacro %}

Le template /template/fruit_edit.html du projet de démonstration fruits-app (voir dépôt GitHub du projet dans le répertoire /python/flask-demos/fruits-app/ ) pourrait alors être réécrit comme suit en utilisant les balises {% import %} et {% include %} :

{% include ’page_header.html’ %}  

{% import ’forms.macro’ as forms %}  

<form  action="{{ url_for(’fruit_edit’, id=id if id>=0 else None ) 

}}" method="post">  

<table border="1">  

 

    <tr>  

           <td>Nom</td>  

           <td>{{ forms.input( ’nom’, value=nom ) }}</td>  

    </tr>  

    <tr>  

           <td>KCal / 100gr</td>  

           <td>{{ forms.input( ’kcal’, value=kcal ) }}  

    </tr>  

 

    <tr><td align="right" colspan="2">  

     {{ forms.input( ’id’, value=id, type=’hidden’ ) }}  

      {{ forms.submit( ’send_fruit’, text=’Envoyer’ ) }}  

      <input type="button" onclick="location.href=

’{{ url_for(’fruit_list’) }}’;" value="Abandonner" />  

         </td></tr>  

</table>  

</form>  

{% include ’page_footer.html’ %}

import ... as

La balise {% import %} propose une syntaxe plus complète permettant d’importer des éléments du fichier dans l’espace de nom courant.

Il est en effet possible de réaliser une importation à l’aide de :

{% from ’forms.macro’ import input as input_field, submit as submit_button %}

Ce qui permet de modifier les appels de :

<td>{{ forms.input( ’nom’, value=nom ) }}</td> 

... 

{{ forms.submit( ’send_fruit’, text=’Envoyer’ ) }}

Vers :

<td>{{ input_field( ’nom’, value=nom ) }}</td> 

... 

{{ submit_button( ’send_fruit’, text=’Envoyer’ ) }}

import ... with context

La balise {% import %} est surtout utilisée pour importer des macros. Elle ne met pas le contexte local à disposition de celles-ci. Cette particularité permet à Jinja de placer le fichier importé en cache.

Jinja propose la syntaxe {% from ’forms.macro’ import input with context %} qui permet au fichier importé d’accéder au contexte. La conséquence directe est l’exclusion du cache. Cette syntaxe est à éviter pour ne pas pénaliser inutilement les performances du moteur de rendu.

 

Voir aussi la balise {% include %} qui, elle, charge le fichier en donnant accès au contexte local.

12. Héritage de template

S’il y a bien une fonctionnalité Jinja à ne pas rater, c’est l’héritage !

Le procédé d’héritage permet de définir un « template de base », comme une page « squelette » qui prévoit l’emplacement de blocs « vides » qui seront définis plus tard dans un autre template Jinja.

Un template Jinja peut alors faire dériver son contenu du « template de base » puis définir ou préciser les blocs du « template de base » à remplir.

L’héritage de template est avant tout utilisé pour définir la structure HTML générale d’un site (en-tête, barre de menu, fil d’Ariane, pied de page, zone d’affichage, etc.) afin de maintenir une uniformité visuelle pour l’ensemble des pages du site.

Les deux illustrations suivantes présentent ce principe d’héritage du template de base.

images/06RI40.pngimages/06RI40.png
 

Principe de l’héritage de template

images/06RI41.pngimages/06RI41.png
 

Principe de l’héritage de template (exemple 2)

a. Les éléments de l’héritage

Comme les illustrations ci-dessus l’indiquent, l’héritage fait intervenir un template de base, un mécanisme d’extension ( {% extends %} ), la définition des blocs ( {% blocks %} ) à préciser dans le template enfant (les templates OSCAR et STEVE).

Plus formellement, l’héritage Jinja est articulé autour des éléments suivants :

b. Heritage-app : l’héritage Jinja par la pratique

Les principes d’héritages Jinja sont abordés dans l’exemple heritage-app disponible sur le dépôt GitHub du projet (dans le répertoire /python/flask-demos/heritage-app/ ).

L’application est constituée des éléments suivants :

heritage-app 

├── app  

│   ├── __init__.py  

│   ├── static  

│   │   └── style.css  

│   ├── templates  

│   │   ├── base.html  

│   │   ├── main.html  

│   │   └── super.html  

│   └── views.py  

└── runapp.py

views.py

Sans surprise, views.py définit les routes / et /super qui font respectivement appel aux templates main.html et super.html.

# coding: utf8  

from app import app  

from flask import render_template  

 

@app.route(’/’)  

@app.route(’/<string:nom>’)  

def main( nom = None ):  

  return render_template( ’main.html’, name=nom )  

 

@app.route(’/super’)  

def super():  

  return render_template( ’super.html’ )

c. Template de base et block

Le « template de base » définit :

Les balises {% block %} ont trois caractéristiques importantes :

1.

Elles sont nommées.

2.

Elles ont accès au contexte local et peuvent donc évaluer des variables et expressions.

3.

Elles peuvent avoir une valeur par défaut, utilisée lorsque le bloc n’est pas redéfini dans le « template enfant ».

Dans l’exemple heritage-app, le « template de base » base.html est constitué comme suit :

<!DOCTYPE HTML>  

<html>  

  <header> 

  {% block head %} 

      <link rel="stylesheet" type="text/css"  

  href="{{ url_for( ’static’, filename=’style.css’ ) }}" />  

      <title>{% block title %}{% endblock %} - Heritage-App</title> 

  {% endblock %} 

  </header>  

  <body>  

   <div id="content">{% block content %}{% endblock %}</div>  

   <div id="footer"> 

       {% block footer %} 

       -- La Maison Pythonic @ <a href="https://github.com/mchobby/la-maison-pythonic">GitHub</a> -- 

       {% endblock %} 

   </div>  

  </body>  

</html>

Ce « template de base » reprend clairement la structure d’une page HTML avec une section en-tête <head> et un corps de page avec <body>.

À noter que le corps de page est déjà scindé en deux éléments (des <div>) permettant d’organiser respectivement le contenu à afficher <div id="content"> et un pied de page <div id="footer">.

Le template de base définit les balises {% block %} suivantes qui devront être remplies par la « page enfant » :

 

Dans les projets plus étendus, il est préférable de séparer les templates et « templates de base ». Une option consiste à placer les « templates de base » dans le sous-répertoire /templates/layout. Par conséquent, la balise extends est utilisée avec la syntaxe {% extends "layout/base.html" %}.

13. Template enfant

Le « template enfant » est celui appelé par une route et il définit :

Dans l’exemple heritage-app, le « template enfant » main.html est constitué comme suit :

01: {% extends "base.html" %}  

02: {% block title %}Accueil{% endblock %}  

03: {% block content %}  

04:    {% if name %}  

05:       <h1>Bienvenu {{name}}</h1>  

06:    {% else %}  

07:       <h1>Page Principale</h1>  

08:       <p>Vous pouvez également appeler la page  

09:          avec <strong>/votre_prénom</strong>  

10:    {% endif %}  

11:    <p> Partir à la découverte de l’héritage  

12:        des templates Jinja. </p>     

13:    <p>Voici le template main.html qui est une  

14:       extension de base.html.<br />  

15:       Cette page définit les blocs (<em>block</em>)  

16:       "title" et "content".</p>  

17:    <p>L’appel de <strong>/super</strong> permet  

18:       de tester l’exécution du "super block".</p>  

19: {% endblock %}

 

À noter que les blocs {% block header %} et {% block footer %} ne sont pas définis. Par conséquent, le moteur de template utilisera les valeurs par défaut définies dans le template de base base.html.

L’exécution de l’application et l’appel de la page racine produisent le résultat suivant :

images/06RI42.pngimages/06RI42.png
 

Résultat de l’appel de la page racine

a. Super bloc

Dans l’exemple heritage-app, le « template de base » base.html définit un bloc head pour couvrir la définition du <header> de la page HTML.

Cela permet de remplacer entièrement le contenu par défaut de l’élément <header> proposé dans le « template de base », une approche radicale, mais fonctionnelle.

Il existe cependant une approche plus élégante utilisant le « super bloc ».

En effet, Jinja propose l’évaluation de la balise {{ super() }} qui permet de récupérer l’évaluation du bloc dans le « template de base » afin de l’insérer dans le template enfant.  

L’exemple ci-dessous propose d’utiliser {{ super() }} pour compléter le contenu du bloc {% block head %} avec l’ajout de la définition CSS suivante :

p.local-def {  

   color: #007700;  

   background-color: #FFFF00;  

   padding: 25px 25px 25px 25px;  

   border-style: solid;  

   border-width: 2px;  

   border-color: #007700;  

}

La route /super fait le rendu du template super.html, template défini comme suit :

01: {% extends "base.html" %}  

02: {% block title %}Super Block{% endblock %}  

03: {% block head %}  

04:     {{ super() }}  

05:     <style type="text/css">  

06:         p.local-def {  

07:         color: #007700;  

08:         background-color: #FFFF00;  

09:         padding: 25px 25px 25px 25px;  

10:         border-style: solid;  

11:         border-width: 2px;  

12:         border-color: #007700;  

13:         }  

14:     </style>  

15: {% endblock %}  

16: {% block content %}  

17:     <h1>Utilisation du Super Block</h1>  

18:     <p> Cette page utilise l’héritage des templates Jinja 

19:        mais en redéfinissant une partie du bloc <strong> 

20:         head</strong> afin d’y ajouter des définitions  

21:         de style CSS. </p>  

22     <p>L’appel de {{ ’{{’ }} super() {{ ’}}’ }} dans le  

23:        bloc <strong>head</strong> permet d’évaluer et  

24:        insérer le contenu du bloc <strong>head défini  

25:        dans le "tempate de base"<strong>.</p>  

26:  

27:     <p class="local-def">Ce &lt;p&gt; utilise la classe  

28:        CSS <strong>local-def</strong> ajoutée dans le  

29:        bloc <strong>head</strong>.</p>  

30: {% endblock %}

 

À noter que le bloc head du template de base n’inclut pas les éléments <header> et </header>, ce qui permet d’en compléter le contenu.

L’exécution de l’application et l’appel de la page /super produisent le résultat suivant :

images/06RI43.pngimages/06RI43.png
 

Utilisation de super() pour compléter un bloc du template

b. Ressources

L’héritage des templates ne se résume pas aux seuls points abordés ci-avant. La documentation Jinja propose également des informations complémentaires : http://jinja.pocoo.org/docs/2.10/templates/#template-inheritance

14. Message Flash

Message Flash (Message Flashing dans la littérature Flask) est une autre fonctionnalité importante de Jinja. Message Flash prend en charge un mécanisme de notification utilisateur. Il permet par exemple d’indiquer un message d’erreur comme « L’adresse doit être mentionnée » lorsque l’utilisateur n’encode pas l’information requise avant de poster un formulaire. Message Flash permet également d’afficher une notification « Enregistrement sauvegardé » en haut d’une liste d’enregistrements ré-affichée suite à une modification réussie.

Le système de Message Flash permet d’enregistrer des messages en fin de requête pour les avoir à disposition lors de l’exécution de la requête suivante (et uniquement la requête suivante).

En utilisant un template de base (voir Héritage Jinja), la re-capture du ou des messages enregistrés peut être réalisée dans le template de base pour organiser leur affichage.

 

Le mécanisme de messages utilise les cookies, ce qui limite la grandeur des messages (ou informations) pouvant être communiqués d’une requête à l’autre. La taille limite du message dépend du navigateur utilisé. Dans certains cas, le navigateur refusera les cookies trop longs, ce qui aura pour conséquence de faire disparaître l’information sans que cela puisse être détecté.

L’exemple flash-message-app disponible sur le dépôt GitHub du projet dans le répertoire /python/flask-demos/flask-app/flash-message-app/ démontre l’usage du Message Flash.

Structure

Le projet utilise la structure suivante :

flash-message-app  

├── app  

│   ├── __init__.py  

│   ├── static  

│   │   └── style.css  

│   ├── templates  

│   │   ├── base.html  

│   │   ├── edit_message.html  

│   │   ├── edit_nom.html  

│   │   └── main.html  

│   └── views.py  

└── runapp.py

Les vues

Le fichier views.py gère les différentes vues, dont l’accueil sur l’URL racine. Cette page donne accès à deux autres pages qui produiront des Messages Flash qui seront alors visibles au retour sur la page principale.

Voici la première partie du fichier qui démontre l’usage élémentaire des Messages Flash.

01: # coding: utf8  

02: from app import app  

03: from flask import render_template, request, \ 

04: redirect, url_for, flash  

05: 

06: app.secret_key = ’la_cle_secrete’  

07:  

08: @app.route(’/’)  

09: def main( nom = None ):  

10:    return render_template( ’main.html’ )  

11:  

12: @app.route(’/edit/name’)  

13: def edit_name():  

14:    return render_template( ’edit_nom.html’ )  

15:  

16: @app.route(’/save/name’, methods=[’POST’] )  

17: def save_name():  

18:         if request.form[’act’] == ’Abandonner’:  

19:                 flash( u’Opération abandonnée’, ’error’ )  

20:         else:         

21:                # Sauvegarder les données  

22: 

23:                flash( u’Nom "%s" enregistré’ % request.form[’name’] )  

24:  

25:         return redirect( url_for( ’main’ ) ) 

...

Le template de base

Le « template de base » base.html est presque identique à l’exemple développé dans la section traitant de l’héritage Jinja.

La différence réside principalement dans la gestion des Messages Flash.

01: <!DOCTYPE HTML>  

02: <html>  

03: <header>  

04:   {% block head %}  

05:       <link rel="stylesheet" type="text/css"  

06:             href="{{ url_for( ’static’, 

07:                      filename=’style.css’ ) }}" />  

08:      <title>{{ title }} - Flash-Message-App</title>  

09:   {% endblock %}  

10: </header>  

11: <body>  

12:   <h1>{{ title }}</h1>  

13    {# Affichage des Message Flash #}  

14:   {% with messages = 

15:           get_flashed_messages(with_categories=true) %}  

16:      {% if messages %}  

17:         <ul class=flashes>  

18:         {% for categ, message in messages %}  

19:            {% if categ == ’error’ %}  

20:               {% set cls = ’error’ %}  

21:            {% else %}  

22:               {% set cls = ’’ %}  

23:            {% endif %}  

24:            <li class="{{ cls }}"> 

25:               {% if categ == ’error’ -%}  

26:                  [{{ categ | upper }}]  

27:               {% endif %} {{ message }} 

28:            </li>  

29:         {% endfor %}  

30:         </ul>  

31:      {% endif %}  

32:   {% endwith %}  

33:   {# Affichage du contenu #}  

34:   <div id="content">{% block content %}{% endblock %}</div>  

35:   <div id="footer">  

36:     {% block footer %}  

26:      -- La Maison Pythonic @  

27:      <a href="https://github.com/mchobby/la-maison-pythonic"> 

28:      GitHub</a> --  

29:      {% endblock %}  

30:   </div>  

31: </body>  

32: </html>

 

La balise {% with %} permet de maintenir la ressource jusqu’à la balise {% endwith %} où celle-ci sera libérée.

images/06RI49.pngimages/06RI49.png
 

Affichage des messages dans un cadre

Page d’accueil

L’affichage de la page d’accueil (sur l’URL racine) est assuré par le fichier main.html.

Ce dernier hérite de base.html et définit les blocs nécessaires.

01: {% extends "base.html" %}  

02: {% set title = "Accueil" %}  

03: {% block content %}  

04:    <p>  

05:      Cette application permet de découvrir les possibilités 

06: du Flash Message (<em>Message Flashing</em>). Les boutons ci- 

07: dessous conduisent vers des pages qui produisent des Flashs 

08:Messages.  

09:     </p>  

10:     <input type="button" value="Saisir Nom" 

11:      onclick="location.href=’{{ url_for("edit_name") }}’;" /> 

12: 

13:     <input type="button" value="Saisir Message" 

14:  onclick="location.href=’{{ url_for("edit_message") }}’;" />  

15: {% endblock %}

images/06RI46.pngimages/06RI46.png
 

Contenu de la page d’accueil

Cette même page d’accueil peut également afficher des Messages Flash produits par le traitement d’autres routes.

images/06RI51.pngimages/06RI51.png
 

Page d’accueil avec affichage d’un Message Flash (produit par la route /saisir/name)

images/06RI48.pngimages/06RI48.png
 

Page d’accueil avec affichage de Message Flash (produit par la route /saisir/message ).

Édition du nom

Le template edit_nom.html permet d’afficher la page de saisie.

images/06RI50.pngimages/06RI50.png
 

Ecran d’édition du nom

Le contenu du template edit_nom.html :

01: {% extends "base.html" %}  

02: {% set title = "Editer Nom" %}  

03: {% block content %}  

04:     <form action="{{ url_for(’save_name’) }}" method="post">  

05:     <table>  

06:       <tr><td>Saisir nom</td>  

07:           <td><input type="text" name="name"  

08:                    value="Saisir un nom" /></td>  

09:       </tr>  

10:       <tr><td colspan="2">  

11:               <input type="submit" name="act" value="Envoyer" />  

12:               <input type="submit" name="act" value="Abandonner" />  

13:      </td></tr>  

14:    </table>  

15:    </form>     

16: {% endblock %}

 

Dans les deux cas, le formulaire est envoyé vers le serveur Flask. La seule différence réside dans la valeur renvoyée pour le champ « act » (qui contient soit « Envoyé », soit « Abandonner »). L’envoi du formulaire vers Flask permet de générer un Message Flash dans les deux cas.

Pour rappel, la route /save/name contient le code de traitement suivant extrait de views.py :

16: @app.route(’/save/name’, methods=[’POST’] )  

17: def save_name():  

18:      if request.form[’act’] == ’Abandonner’:  

19:         flash( u’Opération abandonnée’, ’error’ )  

20:      else:  

21:         # Sauvegarder les données  

22: 

23:         flash( u’Nom "%s" enregistré’ % request.form[’name’] )  

24:  

25:      return redirect( url_for( ’main’ ) )

Le test sur request.form[’act’] permet de détecter quel bouton a été pressé dans le formulaire et d’adapter le Message Flash envoyé. À noter en ligne 19, l’utilisation de la catégorie « error » lors du Message Flash.

Saisie message

Le projet inclut également un second exemple permettant de saisir des messages à flasher. Le template edit_message.html permet de saisir jusqu’à trois messages et éventuellement d’en marquer quelques-uns en catégorie « error ».

Voir le fichier views.py ainsi que les routes /edit/message et /save/message pour plus d’information.

Ces routes permettent de produire les pages suivantes :

images/06RI47.pngimages/06RI47.png
 

Écran de saisie des messages (route /edit/message)

Une fois les messages encodés et le bouton Envoyer pressé, le retour en page d’accueil permet de voir les Messages Flash. Ces Messages Flash ont été communiqués lors de l’appel de /save/message, juste avant la redirection vers la page d’accueil.

images/06RI48.pngimages/06RI48.png
 

Résultat de message encodé sur la page d’accueil

L’écran d’édition contient également un bouton Envoyer+Saisir Nom renvoyant vers l’écran d’édition de nom. Si cela est fonctionnellement inapproprié, ce bouton permet de démontrer la prise en charge des Messages Flash sur toutes les pages produites grâce au « template de base ».

images/06RI52.pngimages/06RI52.png
 

Saisie de messages puis action sur le bouton Envoyer+Saisir Nom

images/06RI53.pngimages/06RI53.png
 

Affichage des Messages Flash partout grâce au template de base

Présentation

1. Préambule

À ce stade, le projet dispose d’objets ESP8266 (sous MicroPython) collectant des données télémétriques envoyées vers le broker MQTT du Raspberry Pi.

Une partie de ces données sont enregistrées dans une base de données SQLite 3 à l’aide du script push-to-db.py.

images/07RI01.pngimages/07RI01.png
 

État du projet

Tout ce qui manque à ce projet, c’est une vitrine pour présenter ces informations de façon attractive ! C’est ce que propose ce chapitre en réalisant un projet Flask exploitant le framework CSS Materialize (materializecss.com).

images/07RI02.pngimages/07RI02.png
 

Application Flash pour afficher des tableaux de bord

2. Dépôt du projet Dashboard

Une copie des fichiers nécessaires est disponible sur le dépôt GitHub de l’ouvrage. Le sous-répertoire /python/dashboard/ contient le projet Flask permettant de créer les tableaux de bord présentés dans ce chapitre.

images/07RI03.pngimages/07RI03.png
 

Dépôt GitHub de l’ouvrage

Le répertoire /python/dashboard/install/ contient des ressources destinées à faciliter l’installation et la mise en œuvre du projet Flask. Ce dernier répertoire n’est pas nécessaire pour exécuter le projet.

Une copie des bases de données avec un échantillon de données est également disponible dans le répertoire /python/dashboard/install/demodb/. Cela permettra d’explorer rapidement le projet sans avoir besoin de mettre tous les autres éléments du livre en œuvre pour collecter les premières données.

3. Éléments principaux

Le tableau de bord s’organise autour des éléments suivants :

Données MQTT - pyhtonic.db

La base de données pythonic.db contient les données MQTT capturées par le script push-to-db.py développé dans le chapitre Persistance des données.

La base de données doit être installée dans le répertoire /var/local/sqlite/ en suivant les recommandations de configuration du chapitre Persistance des données.

Une copie de la base de données avec un échantillon de données est disponible dans le répertoire /python/dashboard/install/demodb/ du dépôt GitHub.

Données du tableau de bord - dashboard.db

La base de données dashboard.db contient les données de configuration des tableaux de bord et de leur contenu (les différents blocs affichés).

La création et l’installation de la base de données dans le répertoire /var/local/sqlite/ seront développés plus loin dans ce chapitre.

Une copie de la base de données avec un échantillon de données est disponible dans le répertoire /python/dashboard/install/demodb/ du dépôt GitHub.

Tableau de bord

Le tableau de bord est articulé autour d’un projet Flask exploitant l’héritage de template, les macros Jinja, les Messages Flash, la connexion à une base de données tels que décrits dans le chapitre Développement web en Python.

Le projet Flask disponible dans le répertoire /python/dashboard/ du dépôt GitHub propose le sous-répertoire applicatif app contenant les ressources nécessaires comme le framework CSS Materialize, des images, etc. L’application Flask peut être facilement démarrée avec la commande python runapp.py.

Par la suite, naviguer sur l’adresse IP du Raspberry Pi (ou son nom sur le réseau local) pour avoir accès au tableau de bord.

http://pythonic.local:5000

images/07RI04.pngimages/07RI04.png
 

Page d’accueil présentant les différents tableaux de bord disponibles

L’anatomie et le fonctionnement d’un projet Flask est décrit en détail dans le chapitre Développement web en Python.

Fichier de configuration

Le projet Flask utilise un fichier de configuration /etc/pythonic/dashboard.cfg qui est un fichier Python. Ce dernier reprend les sources de données (emplacement des bases de données) et configuration du logger.

Si le fichier de configuration est manquant alors le projet charge le fichier de configuration par défaut dashboard.cfg.default présent dans le répertoire applicatif app.

Le contenu du fichier de configuration sera détaillé plus loin dans le chapitre.

À propos de Materialize

Material Design est un concept initié par Google en 2014 (https://material.io/). Il offre une charte graphique avec des règles de conception permettant de réaliser des interfaces graphiques riches accompagnées d’animations et d’effets de transition.

Dans le concept Material Design, un rendu d’information sur un média numérique est comparé à ce même rendu sur une page de papier. Là où une page de papier adopte un rendu « rigide » et définitif, le média numérique a la possibilité de s’adapter et de réarranger son contenu dynamiquement si les conditions de rendu sont modifiées. Ainsi, une rotation de 90° d’une page de papier (vue en paysage) rend le document illisible alors que la même rotation du média numérique (la tablette) peut permettre le réarrangement dynamiquement du contenu avec la mise en avant d’une information par rapport à l’autre. Ce dynamisme s’applique également lors du redimensionnement de la fenêtre/page (possible sur un média numérique, mais difficile sur une feuille de papier).

Cette brève introduction, qui introduit par ailleurs le concept responsive design (rendu qui s’adapte en fonction de l’espace disponible), peut être complétée en consultant les ressources suivantes :

Materialize est un projet Material Design indépendant assorti d’une documentation et d’exemples permettant de créer rapidement et facilement des interfaces web modernes, réactives et responsives.

Le site Materialize propose d’ailleurs une vitrine de réalisations utilisant leur framework (voir le point Showcase sur la page https://materializecss.com/).

images/07RI05.pngimages/07RI05.png
 

Vitrine de projets réalisés avec Materialize

Parmi les vitrines, « Material Admin Template » propose de nombreuses démonstrations dont « Materialized » qui permet d’explorer en profondeur toute la puissance du framework Materialize. Materialize permet de réaliser des interfaces graphiques très évoluées.

Dans les exemples de la catégorie « Material Admin Template », Materialized est une interface proposant une interface très avancée réalisée à l’aide de Materialize.

images/07RI06.pngimages/07RI06.png
 

Materialized : un des exemples d’interface avancée Materialize

Ressource

 

Il existe également une version « Material Design Lite » qui n’utilise pas de code JavaScript. Ce dernier est optimisé pour une utilisation sur périphériques légers et fonctionne correctement sur des navigateurs plus vieux. Voir le lien suivant pour plus d’informations : https://getmdl.io/index.html

4. Fonctionnalités du projet Dashboard

L’application « Tableau de bord » permet de définir une liste de tableaux de bord et leurs contenus (des blocs). Les blocs affichent les informations en provenance d’une base de données Pythonic (données télémétriques/MQTT) dans le tableau de bord.

Le diagramme suivant présente les relations entre les différentes pages disponibles dans l’application « tableau de bord ».

images/07RI07a.pngimages/07RI07a.png
 

Fonctionnalités générales du tableau de bord

Liste des tableaux de bord

La page d’accueil présente une liste des tableaux de bord (Dashboards) définis dans l’application.

images/07RI08.pngimages/07RI08.png
 

Liste des tableaux de bord

1.

L’icône Home permet, à tout moment, de revenir à la page d’accueil.

2.

L’icône Configure permet de configurer les paramètres de l’application. Note : le développement est encore à réaliser.

3.

L’icône + permet d’ajouter un nouvel élément sur la page (un nouveau tableau de bord).

4.

Cliquer sur une entrée (icône ou libellé) pour accéder au contenu du tableau de bord. Le libellé peut également contenir l’identification d’une page spéciale comme {TOPICS}, {DEMO}. La couleur de fond (et la couleur du texte) est définie lors de la création de l’enregistrement.

5.

L’icône Edit permet de modifier les caractéristiques du tableau de bord (icône, nom, couleurs).

6.

L’icône Delete permet d’effacer le tableau de bord de la liste (après confirmation de l’utilisateur).

Créer/modifier un tableau de bord

La page suivante permet de créer et/ou personnaliser l’en-tête d’un tableau de bord.

images/07RI09.pngimages/07RI09.png
 

Création d’un nouveau tableau de bord

La page de création d’un tableau de bord permet de préciser :

La liste déroulante des couleurs utilise un select Materialize pour afficher les pavés de couleur. Les couleurs sont reprises sous forme d’images disponibles dans /static/images/colors/. 

images/07RI10.pngimages/07RI10.png
 

Sélection d’une couleur avec composant select Materialize

La liste déroulante des icônes utilise également un composant select Materialize pour afficher l’icône à sélectionner. Dans l’application, les icônes sont affichées à partir de la font Material+Icons de Google. Par contre, l’affichage des icônes dans la liste nécessite l’emploi d’images (récupérées depuis https://material.io/tools/icons/) et disponibles dans /static/images/icons/.

images/07RI11.pngimages/07RI11.png
 

Sélection d’une icône avec un composant select Materialize

Tableau de bord

L’affichage du tableau de bord (dashboard) reprend différents blocs reproduisant le contenu de messages MQTT enregistrés dans la base de données pythonic.db.

La capture ci-dessous représente le contenu du tableau de bord Maison. La couleur de l’en-tête correspond à celle définie lors de la création du tableau de bord.

images/07RI12.pngimages/07RI12.png
 

Contenu du tableau de bord

1.

Titre du tableau de bord affiché sur le fond de couleur assigné au tableau de bord.

2.

Icône Configure qui permet d’altérer le contenu du tableau de bord. Cliquer sur cette icône affiche une liste des blocs.

3.

Icône + qui permet d’ajouter un nouveau bloc sans passer par la liste des blocs.

4.

Icône Refresh pour recharger immédiatement la page. Notez que la page est rechargée automatiquement toutes les 120 secondes.

5.

Bloc affichant des informations. Le bloc indiqué par le point 5 est un bloc de type icon. Le titre du bloc indique qu’il s’agit de la température de la véranda où il fait 23,91 °C. Le bloc mentionne également l’âge de l’information, à savoir 12 minutes.

6.

Icône permettant d’accéder à l’historique des valeurs (lorsque l’information est disponible).

Liste des blocs

L’icône Configure disponible sur le tableau de bord affiche une liste des blocs du tableau de bord.

images/07RI13.pngimages/07RI13.png
 

Liste des blocs du tableau de bord Maison

Cette page n’affiche pas de grande nouveauté et sert surtout à modifier le contenu du dashboard en permettant d’ajouter, modifier ou effacer des blocs du tableau de bord.

1.

Le titre permet de revenir à l’affichage du tableau de bord.

2.

L’icône Retour permet de revenir sur la page précédente (donc au tableau de bord).

Créer/modifier un bloc

La configuration d’un bloc (ajouter/modifier) n’est pas vraiment plus complexe que l’ajout d’un tableau de bord. La différence réside principalement dans la sélection de la source, du topic et du type de bloc.

images/07RI14.pngimages/07RI14.png
 

Écran de modification d’un bloc

Hormis l’icône, la configuration de la couleur de fond et du texte du bloc (point déjà abordé plus haut), cette page permet de configurer les éléments suivants :

1.

Titre du bloc qui est affiché en haut de celui-ci.

2.

Source de données qui contient les informations à afficher dans le tableau de bord (pythonic.db, la base de données chargée par push-to-db.py). Notez que les sources de données disponibles sont définies dans le fichier de configuration.

3.

Sélection d’un des topics disponibles dans la source de données. Cette liste est remplie une fois la source de données sélectionnée.

4.

Type de bloc à afficher.

L’application Dashboard prévoit plusieurs types de blocs pris en charge par des macros Jinja au moment du rendu.

La sélection du type de bloc se fait à l’aide d’un composant select Materialize qui affiche des logos pour identifier plus facilement le type de bloc. Les logos sont repris sous forme d’images disponibles dans /static/images/block_types/.

images/07RI15.pngimages/07RI15.png
 

Composant select Materialize pour sélectionner le type de bloc

 

Au moment de la rédaction de cet ouvrage, seuls les blocs icon et big_text sont achevés et pleinement fonctionnels.

 

La page d’édition de bloc contient également des champs cachés concernant le type d’historique à afficher (et sa longueur). Le seul type d’historique prévu dans l’ouvrage est LIST. Celui-ci affiche la liste des valeurs historiques. Il est tout à fait envisageable de proposer d’autres types d’historiques comme un graphique de l’évolution des valeurs. Dans ce cas, les champs cachés devraient être remplacés par un composant select Materialize.

 

La page d’édition du bloc est également destinée à recevoir un autre champ (actuellement masqué) nommé block_config. Celui-ci est destiné à recevoir le paramétrage d’un bloc spécifique au format JSON. Ce champ sera mis en œuvre lors de l’implémentation du bloc SWITCH.

Les types de blocs du Dashboard

Le bloc BIG_TEXT affiche le contenu du message en grand dans le bloc.

images/07RI18.pngimages/07RI18.png
 

Bloc BIG_TEXT

Le bloc affiche les informations suivantes :

1.

Affichage du titre du bloc.

2.

Affichage du message MQTT (sans aucune mise en forme).

3.

Affichage d’un pied de bloc (footer) avec le délai depuis la dernière mise à jour.

Le bloc ICON affiche une icône en plus du message.

images/07RI19.pngimages/07RI19.png
 

Bloc ICON

Le bloc affiche les informations suivantes :

1.

Affichage du titre du bloc.

2.

Affichage de l’icône configurée pour le bloc.

3.

Affichage du message MQTT (sans aucune mise en forme).

4.

Affichage d’un pied de bloc (footer) avec le délai depuis la dernière mise à jour.

Le bloc SWITCH (encore inachevé) affiche un interrupteur.

images/07RI20.pngimages/07RI20.png
 

Bloc SWITCH

1.

Affichage du titre du bloc.

2.

Une icône symbolisant l’état marche/arrêt.

3.

Un interrupteur permettant de passer d’un état à l’autre.

4.

Affichage de la valeur du topic (avec un message complémentaire étant donné que le bloc est en cours de développement).

5.

Affichage d’un pied de bloc (footer) avec le délai depuis la dernière mise à jour.

Historique des valeurs

Lorsque la table des valeurs topicmsg de la source de données (pythonic.db) contient une information d’historique (tsname assigné, cf. Persistance des données - Approche base de données de push-to-db), alors le bloc affiche une icône historique en haut à droite du bloc.

images/05RI04.pngimages/05RI04.png
 

Source de données, stockage des messages

images/07RI16.pngimages/07RI16.png
 

Icône historique sur le bloc (la loupe)

Cliquer sur l’icône historique permet d’accéder à l’historique (LIST) des valeurs.

images/07RI17.pngimages/07RI17.png
 

Historique des valeurs

Les pages spéciales

L’application inclut également des pages spéciales qui peuvent être appelées depuis la liste des tableaux de bord en saisissant leurs noms dans le titre du tableau de bord.

Page spéciale

Description

{TOPICS}

Liste des topics et messages disponibles dans la base de données pythonic.db (ou toute autre base de données renseignée dans le fichier de configuration).

{DEMO}

Page d’accueil des démonstrations Materialize. Initialement, cette page (et ses sous-pages) était utilisée pour tester l’élaboration de l’interface avec Materialize et le code HTML correspondant. Tester et inspecter le code HTML est riche d’enseignements, raison pour laquelle les pages DEMO restent disponibles.

Cette page reprend également des liens vers les ressources icon et colors de Materialize.

Cette page est accessible directement par l’intermédiaire de l’URL /demo.

images/07RI21.pngimages/07RI21.pngimages/07RI21.pngimages/07RI21.png
 

Liste des topics disponibles avec {TOPICS}

images/07RI22.pngimages/07RI22.png
 

Page de démonstration materialize {DEMO}

Structure HTML

1. Disposition de la page

images/07RI23.pngimages/07RI23.png
 

Exemple de contenu

La page de démonstration ci-dessus peut être découpée en entités logiques.

images/07RI24.pngimages/07RI24.png
 

Découpage HTML en entités logiques

Le tableau ci-dessous reprend la définition des différentes entités avec leur correspondance dans le code HTML.

Entité

Description

Lignes HTML

1

Barre de navigation

Contient le titre à gauche et les icônes d’action à droite.

28 à 40

2

Titre

30

3

Actions

Icônes permettant d’initier une action.

31 à 38

4

Contenu

Bloc contenant le contenu de la page.

42 à 59

5

Bloc

Bloc de type ICON.

45 à 51

6

Bloc

Bloc de type BIG_TEXT avec un footer.

45 à 57

La page est découpée en sections correspondant au code HTML ci-dessous :
 

01: <!DOCTYPE html>  

02: <html>  

03: <head>  

04:  <!--Import Google Icon Font-->  

05:  <link  

06: href="https://fonts.googleapis.com/icon?family=Material+Icons"  

07:  rel="stylesheet">  

08:  <!--Import materialize.css-->  

09:  <link type="text/css" rel="stylesheet"  

10:        href="css/materialize.min.css"   

11:        media="screen,projection"/>  

12:  <meta charset="UTF-8">  

13:  <!--Let browser know website is optimized for mobile-->  

14:  <meta name="viewport"  

15:        content="width=device-width, initial-scale=1.0"/>  

16:  <style type="text/css">  

17:    .blocs{  

18:    height: 250px;  

19:    /*background-color: #EFE;*/  

20:    }  

21:    .blocs p.foot{  

22:        font-size: 12px;  

23:        color:#ccc;  

24:    }  

25:   </style>  

26: </head>  

27: <body>  

28:  <nav>  

29:    <div class="nav-wrapper">  

30:        <a href="#!" class="left">Pythonic</a>  

31:        <ul class="right">  

32:            <li><a href="#"> 

33:                <i class="material-icons">lock_outline</i></a> 

34:           </li>  

35:            <li><a href="#"> 

36:                <i class="material-icons">import_export</i></a> 

37:            </li>  

38:        </ul>  

39:    </div>  

40:  </nav>  

41:     

42:  <div class="container">  

43:    <!-- Page Content goes here -->  

44:    <div class="row">  

45:       <div class="center blocs col s12 m4 l3">  

46:         <h4>Météo</h4>  

47:         <i class="material-icons"  

48:            style="font-size:6rem;">wb_sunny</i>  

49:         <br>  

50:         <h5>12°C</h5>  

51:       </div> 

52:  

53:       <div class="center blocs large col s12 m4 l3">  

54:         <h4>Ext Temp</h4>  

55:         <h2>28°C</h2><br>  

56:         <p class="foot">58 seconds ago</p>  

57:       </div>  

58:    </div>  

59:  </div>  

60:     

61:   <!--Import jQuery before materialize.js-->  

62:   <script type="text/javascript"  

63:       src="https://code.jquery.com/jquery-3.2.1.min.js">    

64:  </script>  

65:  <script type="text/javascript"  

66:        src="js/materialize.min.js">  

67:  </script>  

68: </body>  

69: </html>

Hormis les différentes entités reprises ci-avant, il y a encore quelques points notables :

Organisation du contenu avec une grille Materialize

Le contenu de la page (<div class="container">) est organisé sous forme de grille, donc avec des lignes (row) et des colonnes (col).

Dans Materialize, chaque ligne fait 12 colonnes de large (toutes les colonnes ayant la même largeur).

images/07RI25.pngimages/07RI25.png
 

Un contenu est donc organisé en plusieurs lignes (<div class="row">) contenant chacune des cellules (<div class="col">) pouvant éventuellement s’étendre sur plusieurs colonnes. Chaque cellule <div> est identifiée par sa classe col.

Dans l’exemple, la cellule <div class="center blocs col s12 m4 l3"> mentionne également d’autres classes. La classe s12 mentionne que sur un petit écran (small) la cellule fera 12 colonnes de large. Sur un smartphone, il y aura donc une cellule par ligne, les autres cellules seront alors renvoyées à la ligne.

images/07RI26.pngimages/07RI26.png
 

Affichage sur un écran étroit

La classe m4 mentionne que sur un écran de taille moyenne (medium), la cellule occupera quatre colonnes. Finalement, la classe l3 (la lettre « L » et le chiffre 3) indique que sur un écran large (large) la cellule occupera trois colonnes.

La classe center justifie le contenu de l’affichage dans la cellule, tandis que la classe blocs spécifie le style propre aux blocs d’informations affichés dans le tableau de bord.

Plus d’informations sur les grilles sont disponibles sur le lien : https://materializecss.com/grid.html

2. Les blocs d’informations

Bloc ICON

images/07RI27.pngimages/07RI27.png
 

Bloc ICON

Il correspond au code HTML suivant dont les différents éléments ont déjà été traités précédemment.

01: <div class="center blocs col s12 m4 l3">  

02:   <h4>Météo</h4>  

03:   <i class="material-icons"  

04:      style="font-size:6rem;">wb_sunny</i>  

05:   <br>  

06:   <h5>12°C</h5> 

07: </div>

Bloc BIG_TEXT

images/07RI28.pngimages/07RI28.png
 

Bloc BIG_TEXT

Il correspond au code HTML suivant, sans surprise particulière :

01: <div class="center blocs large col s12 m4 l3">  

02:    <h4>Ext Temp</h4>  

03:    <h2>28°C</h2><br>  

04:    <p class="foot">58 seconds ago</p>  

05:  </div>

Bloc SWITCH

images/07RI29.pngimages/07RI29.png
 

Bloc SWITCH

Il correspond au code HTML suivant :

01: <div class="center blocs col s12 m4 l3"> 

02:    <h4>Lights</h4> 

03:    <i class="material-icons"  

04:       style="font-size:6rem;">wb_incandescent</i> 

05:    <div class="switch"> 

06:       <label> 

07:          Off 

08:          <input type="checkbox"> 

09:          <span class="lever"></span> 

10:          On 

11:       </label> 

12:    </div> 

13:    <p class="foot">57 seconds ago</p> 

14: </div>

La particularité réside dans les lignes 5 à 12 créant l’interrupteur.

Plus d’informations sur le switch sont disponibles sur : https://materializecss.com/switches.html.

3. La liste

Materialize permet de réaliser facilement des listes avec icônes (aussi bien à droite qu’à gauche).

images/07RI32.pngimages/07RI32.png
 

Liste Materialize

La liste est affichée dans la zone de contenu (donc dans l’élément <div class="container">).

01: <div class="row">  

02:   <ul class="collection with-header">  

03:      <li class="collection-header">  

04:         <h4>Liste basique</h4>  

05:      </li>  

06:      <li class="collection-item">  

07:         <div>hertz  

08:            <a href="#!" class="secondary-content">  

09:               <i class="material-icons">send</i>  

10:            </a>  

11:         </div>  

12:      </li>  

13:      <li class="collection-item">  

14:         <div>newton<a href="#!" class="secondary-content">  

15:         <i class="material-icons">send</i></a></div>  

16:      </li>  

17:      <li class="collection-item">  

18:         <div>pascal<a href="#!" class="secondary-content">  

19:         <i class="material-icons">send</i></a></div>  

20:      </li>  

21:      <li class="collection-item">  

22:         <div>joule<a href="#!" class="secondary-content">  

23:         <i class="material-icons">send</i></a></div>  

24:      </li>  

25:   </ul>  

26: </div>

Template Jinja

1. Le template de base

Toutes les pages du tableau de bord sont organisées avec les mêmes éléments HTML tels que ceux décrits dans la section précédente.

images/07RI24.pngimages/07RI24.png
 

Découpage HTML en entités logiques

Cette découpe est utilisée pour créer un template de base Jinja. Le graphique ci-dessous et la table qui suit reprennent les différents éléments Jinja placés dans le template de base, éléments à définir dans le template dérivé.

images/07RI30.pngimages/07RI30.png
 

Éléments du template Jinja

Entité

Description et template Jinja

1

Icône Home

{{ home_icon }} : nom de l’icône Materialize à afficher. Icône home par défaut.

{{ home_target }} : URL de destination de l’icône. La racine (/) par défaut.

2

Titre

{{ title }} : Titre affiché dans la barre de navigation (et la page HTML). <No title> par défaut.

{{ title_url }} : URL de destination du titre. #! par défaut.

3

Barre de navigation

{{ dash_color }} : couleur de fond pour le tableau de bord utilisée comme couleur de fond de la barre de navigation. Aucune couleur par défaut.

4

Bloc d’action

{% block actions %} : liste des actions de la page. Par défaut, il affiche le contenu de démonstration suivant :

<li><a href="#"><i class="material-icons">lock_outline</i> </a></li>  

<li><a href="#"><i class="material-icons">import_export</i> </a></li>  

<li><a href="#"><i class="material-icons">build</i> </a></li>  

<li><a href="#"><i class="material-icons">add</i> </a></li>

5

Contenu

{% block content %} : contenu à afficher dans la page. Vide par défaut.

 

En-tête HTML

Bien que non visible dans la capture d’écran, la balise {% block head %} permet de remplacer l’en-tête de la page HTML.

 

Code JavaScript

Le template contient également une balise {% block javascript %} permettant d’injecter du code JavaScript dans la page.

 

JavaScript - onDocumentReady

Le template contient également une balise {% block onDocumentReady %} qui permet d’injecter du code JavaScript en fin d’appel de $(document).ready().

Le contenu de la page de base HTML est composé comme suit :
 

01:<!DOCTYPE html>  

02:<html>  

03:<head>  

04: {% block head %}  

05: <title>{{ title | default("") }} - Pythonic</title>  

06: <link  

07: href="https://fonts.googleapis.com/icon?family=Material+Icons"  

08:       rel="stylesheet" />  

09: <link type="text/css" rel="stylesheet"  

10:       href="{{ url_for(’static’ , 

11:                filename=’css/materialize.min.css’) }}"   

12:       media="screen,projection" />  

13: <meta charset="UTF-8">  

14:  

15: <meta name="viewport"  

16:    content="width=device-width, initial-scale=1.0"/>  

17:  

18: <style type="text/css">  

19:    .blocs{ height: 250px; }  

20:    .blocs p.foot{  

21:        font-size: 12px;  

22:        color:#ccc; }  

23: </style>  

24: {% endblock %}  

25:</head>  

26: 

27:<body>  

28:    <nav>  

29:    <div class="nav-wrapper {{ dash_color | default(’’) }}">  

30:      <ul class="left">  

31:        <li>  

32:        <a {% if dash_color == "white" %}class="black-text" 

33:           {% endif %}  

34:            href="{{ home_target | default(’/’) }}">  

35:           <i class="material-icons"> 

36:             {{ home_icon | default(’home’) }} 

37:           </i>  

38:        </a>  

39:        </li>  

40:     </ul>  

41:   

42:     <a  href="{% if title_url %}{{ title_url }} 

43:               {% else %}#!{% endif %}"  

44:         class="left">  

45:       <h5 {% if dash_color == "white" %} 

46:            class="black-text"{% endif %}>  

47:         {{ title | default("<no title>") }}  

48:       </h5>  

49:     </a>  

50:  

51:     <ul class="right">  

52:        {% block actions %}  

53:        <li> 

54: <a href="#"><i class="material-icons">lock_outline</i></a> 

55:            </li>  

56:        <li> 

57: <a href="#"><i class="material-icons">import_export</i></a> 

58:            </li>  

59:        <li> 

60: <a href="#"><i class="material-icons">build</i></a> 

61:            </li>  

62:        <li> 

63: <a href="#"><i class="material-icons">add</i></a> 

64:             </li>  

65:         {% endblock %}  

66:     </ul>  

67:    </div>  

68:    </nav>  

69: 

70:    <div class="container">  

71:      {% block content %}{% endblock %}  

72:    </div>  

73:        

74:    <script type="text/javascript"  

75:        src="https://code.jquery.com/jquery-3.2.1.min.js">  

76:    </script>  

77:    <script type="text/javascript"  

78:        src="{{ url_for( ’static’, 

79:                filename=’js/materialize.min.js’) }}">  

80:    </script>  

81: 

82:    <script type="text/javascript">  

83:    <!--  

84:    M.AutoInit();  

85:    $(document).ready(function(){  

86:      {#- Needed for html select ui -#}  

87:      $(’select’).formSelect();  

88:       

89:      {#- Toasting Flash Messages #}  

90:      {% with messages = get_flashed_messages( 

91:              with_categories=true) -%}  

92:        {%- if messages -%}  

93:          {%- for categ, message in messages -%}  

94:            {%- if categ == ’error’ -%}  

95:              {%- set cls = ’red-text’ -%}  

96:              {%- set msg = ’[ERREUR] ’+ message -%}  

97:              M.toast({html: "<strong>{{ msg }}</strong>", 

98:                       classes: "{{ cls }}" });  

99:            {%- else -%}  

100:             {%- set cls = ’’ -%}  

101:             {%- set msg = message -%}  

102:              M.toast({html: "{{ msg }}",  

103:                       classes: "{{ cls }}" });  

104:            {%- endif -%}  

105:          {% endfor -%}  

106:        {%- endif -%}  

107:      {%- endwith -%}  

108:      // M.toast({html: ’Flash done’});  

109: 

110:      {#- Custom onDocumentReady Javascrit content -#}  

111:      {% block onDocumentReady %}{% endblock %}  

112:    });  

113:  

114:    {% block javascript %}  

115:    {% endblock %}  

116:   -->  

117:   </script>  

118:</body>  

119:</html>

Dans le code ci-dessus, les éléments remarquables sont les suivants :

Les Toast Materialize

Les Toasts permettent d’afficher temporairement des messages d’alerte à l’utilisateur. Les Toasts ne sont pas invasifs et s’adaptent à la taille de l’écran. Ils peuvent avoir des styles CSS différents, des formes différentes, du contenu HTML personnalisé, des inclusions de bouton, etc.

images/07RI31.pngimages/07RI31.png
 

Affichage de toast lors de la validation avant sauvegarde

Un Toast peut être affiché avec un appel JavaScript à M.toast(). La fonction reçoit un dictionnaire de paramètres dont l’entrée html contient le message.

M.toast( {html: ’Un message Flash’} );

Le dictionnaire peut également contenir une série de classes CSS pour personnaliser le contenu du Toast. L’exemple suivant affiche le message en rouge.

M.toast( {html: ’Alerte!’ , classes: ’red-text’} );

Le site materializecss.com propose une page d’information sur les Toasts : https://materializecss.com/toasts.html

2. Utilisation du template de base

L’utilisation du template de base est abordée avec le template dash_list.html qui affiche la liste des tableaux de bord produite sur l’URL racine.

images/07RI33.pngimages/07RI33.png
 

Liste des tableaux de bord

Le template dash_list.html est utilisé par la fonction de traitement main().

@app.route(’/’)  

def main():  

    """ Liste des tableaux de bords """  

    dashdb = get_db( ’db’ )  

    # Liste des tableaux de bords 

    rows = dashdb.dashes()  

    # Info sur le nom de l’application  

    application = dashdb.application()  

 

    return render_template( ’dash_list.html’,  

                             dash_list=rows, 

                             application=application )

La fonction de traitement utilise le template dash_list.html pour construire le rendu de la page. L’héritage de template est exploité avec le template de base base.html pour réaliser le rendu final.

Notez que la fonction de traitement communique deux paramètres au template :

01: {% extends "base.html" %}  

02: {% set title = application.label |  

03:        default( ’Pythonic’, True ) %}  

04: {% set dash_color = application.color %}  

05: {% block actions %}  

06:     <li> 

07:        <a href="{{ url_for(’app_config’) }}"> 

08:          <i class="material-icons">build</i> 

09:        </a> 

10:     </li>  

11:     <li> 

12:        <a href="{{ url_for(’dashboard_add’) }}"> 

13:          <i class="material-icons">add</i> 

14:        </a> 

15:     </li>  

16: {% endblock %}  

17: {% block content %}  

18: <div class="row">  

19:  {% if dash_list | length <= 0 %}  

20:  <div class="col s12">  

21:  <div class="amber lighten-3">  

22:  <div class="card-content black-text">  

23:    <span class="card-title">  

24:      <strong>Truc et astuce</strong>  

25:    </span>  

26:    <p>Il n’y a pas encore de dashboard disponible.<br />  

27:       Cliquer sur l’icone ’+’ en haut à droite pour  

28:       ajouter un premier dashboard.<br/></p>  

29:  </div>  

30:  </div>  

31:  </div>     

32:  {% endif %}  

33: 

34:  <ul class="collection with-header">  

35:    <li class="collection-header"><h4>Dashboards</h4>  

36:    </li>  

37:    {% for r in dash_list -%}  

38:      {% if r[’label’] | special_page  %}  

39:         {% set url = url_for(’special_page’,  

40:                        name=r[’label’] | special_page ) %}  

41:      {% else %}  

42:         {% set url = url_for(’dashboard’, id=r[’id’]) %}  

43:      {% endif %}  

44: 

45:   {% set url_del = url_for(’dashboard_delete’, id=r[’id’]) %}  

46:   {% set url_edit = url_for(’dashboard_add’, id=r[’id’]) %}  

47:            

48:      {% if not(r[’icon’]) or (r[’icon’] | length <= 0) %}  

49:          {% set icon = "visibility" %}  

50:      {% else %}  

51:          {% set icon = r[’icon’] %}  

52:      {% endif %}  

53:         

54:      <li class="collection-item {{ r[’color’] }}">  

55:      <div>  

56: 

57:      <a href="{{ url }}"  

58:         class="secondary-content left  

59:         {{ r[’color_text’] | default(’black’, true) }}-text">  

60:         <i class="material-icons">{{ icon }}</i>  

61:      </a>  

62: 

63:      <a href="{{ url }}"  

64:  class="{{ r[’color_text’] | default(’black’, true) }}-text">  

65:        &nbsp;&nbsp;{{ r[’label’] }}  

66:      </a>  

67: 

68:      <a href="{{ url_del }}"  

69:  class="secondary-content {{ r[’color_text’] |  

70:                             default(’black’, true) }}-text">  

71:      <i class="material-icons">delete</i>  

72:      </a>  

73: 

74:      <a href="{{ url_edit }}"  

75:  class="secondary-content {{ r[’color_text’] |  

76:                              default(’black’, true) }}-text">  

77:      <i class="material-icons">edit</i>  

78:      </a> 

79: 

80:      </div>  

81:      </li>  

82:    {% endfor %}  

83:   </ul>  

84: </div>  

85: {% endblock %}

Pour commencer, le template dash_list.html hérite du template base.html en utilisant {% extends "base.html" %}.

Ensuite, le template définit les éléments attendus par le template de base :

Plus en détail :

 

Le filtre Jinja special_page extrait le texte entre des balises « {} » et renvoie la valeur en majuscules. S’il n’y a rien à extraire, alors le filtre retourne None. Par conséquent, le test {% if r[’label’] | special_page %} est vrai si r[’label’] contient une valeur similaire à « {topics} ». Le même test serait faux si r[’label’] contient la valeur « Maison ». Le filtre Jinja personnalisé special_page est défini dans le views.py.

Configuration

1. Base de données dashboard.db

Les configurations des tableaux de bord sont stockées dans une base de données SQLite nommée dashboard.db.

a. Schéma de la base de données

images/07RI34.pngimages/07RI34.png
 

Schéma de la base de données dashboard. Réalisé avec draw.io

Table application

Cette table, destinée à ne contenir qu’un seul enregistrement, définit des informations propres à l’application comme le libellé sur l’écran d’accueil.

Table dashes

Cette table contient une liste des tableaux de bord disponibles dans l’application.

Table dash_blocks

Cette table contient la définition des blocs affichés dans les différents tableaux de bord.

Le graphe ci-dessous indique comment le fichier de configuration est exploité pour accéder à la base de données contenant les données MQTT enregistrées par push-to-db.py.

images/07RI35.pngimages/07RI35.png
 

Extraction des messages MQTT

b. Répertoire de stockage

Comme pour le script push-to-db.py, la base de données est stockée dans le répertoire /var/local/sqlite/ en appliquant la configuration des droits permettant l’accès à l’utilisateur pi et au gestionnaire de service. Voir le script /python/dashboard/install/setup.sh dans le dépôt GitHub du projet et les explications détaillées concernant le « script d’installation » de push-to-db.py (cf. Persistance des données - Configuration de push-to-db). Dans les deux cas, la configuration se déroule de façon identique.

c. Création des tables de Dashboard

Les tables sont créées à l’aide du script SQL createdb.sql détaillé ci-dessous. Le script est également disponible sur le dépôt GitHub du projet dans le fichier /python/dashboard/install/createdb.sql.

create table application (  

 id integer primary key,  

 label text not null  

);  

 

create table dashes (  

 id integer primary key,  

 label text not null,  

 icon text,  

 color text not null,  

 color_text text  

);  

 

create table dash_blocks (  

 id integer primary key,  

 dash_id integer not null,  

 title text not null,  

 icon text,  

 color text,  

 color_text text,  

 block_type text not null,  

 block_config text,  

 source text not null,  

 topic text not null,  

 hist_type text,  

 hist_size integer,  

 

 FOREIGN KEY (dash_id) REFERENCES dashes(id)  

);

L’utilitaire sqlite3 est utilisé pour créer la base de données. Si SQLite 3 n’est pas encore disponible, il peut être installé avec la commande sudo apt-get install sqlite.

Par la suite, la base de données peut être créée à l’aide de la commande :

pi@pythonic:~ $ cat createdb.sql | sqlite3 dashboard.db

Avant d’être déplacée dans le répertoire /var/local/sqlite.

 

Notez que la création et l’accès à la base de données dans le répertoire /var/local/sqlite nécessitent une configuration de droit particulière. Ce point est détaillé dans le script d’installation setup.sh.

d. Copie de la base de données

Une copie de démonstration de la base de données est disponible sur le dépôt GitHub du projet dans le répertoire /python/dashboard/install/demodb/.

2. Fichier de configuration de Dashboard

Dans le cadre d’un projet Flask, le fichier de configuration doit être un fichier Python.

Le projet dashboard utilise également un fichier de configuration Python, mais avec un mécanisme de chargement dynamique :

1.

Charger le fichier /etc/pythonic/dashboard.cfg s’il est présent.

2.

Sinon le fichier de configuration par défaut sera chargé depuis le répertoire de l’application (soit le fichier app/dashboard.cfg.default).

Le fichier de configuration est constitué comme suit :

01: # coding: utf8  

02: import sys  

 

03: db=’/var/local/sqlite/dashboard.db’  

04: db_class=’DashboardDB’  

05: 

06: # Sources - les sources de données  

07: data_sources = [ ’db_pythonic’ ]  

08: 

09: # Accès aux topics collectés par push-to-db.py  

10: db_pythonic=’/var/local/sqlite/pythonic.db’  

11: db_pythonic_class = ’PythonicDB’  

12: 

13: # Temps de rafraîchissement en seconde  

14: refresh_time=120  

15: 

16: SECRET_KEY = b’dezaijéàç6848eankeazopfr)àei’  

17: 

18: # Config pour capturer tous les logs  

19: logger_config = {  

20:    ’version’: 1,  

21:    ’formatters’: {  

22:                 ’default’: {  

23:            ’format’: ’[%(asctime)s] x %(levelname)s in %(module)s: %(message)s’ 

24:         }  

25:     },  

26:     ’handlers’: {  

27:        ’wsgi’: {  

28:           ’class’: ’logging.StreamHandler’,  

29:           ’stream’: sys.stdout, 

30:           ’formatter’: ’default’  

31:        }  

32:     },  

33:           ’loggers’ : {  

34:        ’root’: {  

35:           ’level’: ’DEBUG’,  

36:                  ’handlers’: [’wsgi’]  

37:        }  

38:     }  

39: }

Chargement dynamique du fichier de configuration

Le chargement dynamique du fichier de configuration est pris en charge par le fichier __init__.py et plus précisément avec le code suivant :

01: # coding: utf8  

02: # Importer la bibliothèque Flask  

03: from flask import Flask  

04: from logging.config import dictConfig  

05: import os.path  

06:  

07: # Initialise l’application Flask  

08: app = Flask( __name__ )  

09: configuration = None  

10:  

11: CONFIG_FILE = ’/etc/pythonic/dashboard.cfg’  

12: if os.path.exists( CONFIG_FILE ):  

13:     app.config.from_pyfile( CONFIG_FILE )  

14:     print( ’config loaded from %s’ % CONFIG_FILE)  

15:     # Chargement de la configuration en mémoire 

16:     import imp  

17:     configuration = imp.load_source( ’configuration’, 

18:         CONFIG_FILE )  

19: else:  

20:     # Charger le fichier par défaut  

21:     print( ’loading config from dashboard.cfg.default’)  

22:     # appliquer la configuration à l’application Flask  

23         app.config.from_pyfile( ’dashboard.cfg.default’ )  

24:         # charge la configuration en mémoire  

25:         #    (chargement relatif a runapp.py)  

26:     import imp  

27:     configuration = imp.load_source( ’configuration’,  

28:         ’app/dashboard.cfg.default’ )  

29: 

30: # Appliquer la configuration du logger 

31: dictConfig( configuration.logger_config )

Détails de l’application Flask

1. Répertoires et fichiers

dashboard  

├── app  

│   ├── __init__.py  

│   ├── dashboard.cfg.default  

│   ├── models.py  

│   ├── views_demo.py  

│   ├── views_history.py  

│   ├── views.py  

│   ├── views_special.py  

│   ├── static  

│   │   ├── css  

│   │   │   ├── materialize.css  

│   │   │   └── materialize.min.css  

│   │   ├── fonts  

│   │   │   └── roboto  

│   │   ├── images  

│   │   │   ├── block_types  

│   │   │   │   ├── big_text.png  

│   │   │   │   ├── icon.png  

│   │   │   │   └── switch.png  

│   │   │   ├── colors  

│   │   │   │   ├── amber.png  

│   │   │   │   ├── ...  

│   │   │   │   └── yellow.png  

│   │   │   └── icons  

│   │   │       ├── M  

│   │   │       │   ├── accessibility.png  

│   │   │       │   ├── ...  

│   │   │       │   └── wb_sunny.png  

│   │   │       └── readme.md  

│   │   └── js  

│   │       ├── materialize.js  

│   │       └── materialize.min.js  

│   └── templates  

│       ├── base.html  

│       ├── block_del_confirm.html  

│       ├── block_edit.html  

│       ├── block_list.html  

│       ├── dashboard.html  

│       ├── dash_del_confirm.html  

│       ├── dash_edit.html  

│       ├── dash_list.html  

│       ├── demo  

│       │   ├── demo_base.html  

│       │   ├── demo_form.html  

│       │   ├── demo.html  

│       │   ├── demo_list.html  

│       │   └── demo_main.html  

│       ├── history  

│       │   └── list.html  

│       ├── macro  

│       │   ├── block.macro  

│       │   └── M_input.macro  

│       └── special  

│           └── topic_list.html  

├── install  

│   ├── createdb.sql  

│   ├── dashboard.service.sample  

│   ├── demodb  

│   │   ├── dashboard.db  

│   │   └── pythonic.db  

│   └── setup.sh  

└── runapp.py

Le premier niveau du projet Dashboard est scindé en trois sous-répertoires :

Sous-répertoire install

Ce sous-répertoire contient des éléments permettant de faciliter la mise en place du projet.

Sous-répertoire app

Ce sous-répertoire contient les éléments de l’application Flask ainsi que les diverses ressources.

Les fichiers views.py

Un fichier views.py contient la définition des routes et les fonctions de traitement correspondantes. Dans cette application Flask, les routes ont été réparties dans plusieurs fichiers views en fonction du domaine d’application.

Le fichier models.py

Le fichier models.py contient toutes les fonctions, les classes et les méthodes permettant d’obtenir les informations depuis les diverses bases de données.

Le fichier models.py est utilisé pour accéder aussi bien à la base de données de configuration (dashboard.py) qu’à la base de données MQTT (pythonic.db).

Le contenu de models.py fera l’objet d’un point spécifique dans ce chapitre.

Les fichiers templates

Le sous-répertoire templates contient les différents templates Jinja utilisés par les fonctions de traitement pour réaliser le rendu des pages. Déjà développé en début de chapitre, l’héritage de template est mis à contribution pour obtenir un rendu uniforme. Ainsi, tous les fichiers de template héritent de base.html.

Comme pour les vues, les éléments sont subdivisés par domaine d’application.

Les fichiers Macro

Le répertoire templates contient également des macros Jinja. Ces dernières sont stockées dans le sous-répertoire templates/macro.

Les fichiers statiques

Le répertoire app contient de nombreuses ressources statiques réparties dans différents sous-répertoires.

images/07RI36.pngimages/07RI36.png
 

Icônes block_type pour champs <select> Materialize

images/07RI37.pngimages/07RI37.png
 

Icône couleurs pour champs <select> Materialize

images/07RI38.pngimages/07RI38.png
 

Icônes pour champs <select> Materialize

 

Il n’a pas été possible d’utiliser une icône Materialize (ex. : <i class="material-icons">wb_sunny</i>) comme image dans un champ <select> Materialize. Il a donc été nécessaire de créer des pictogrammes pour les icônes « pictogramme » afin de pouvoir les afficher dans un champ <select> Materialize. Hormis ce cas de figure, lors du rendu de l’icône dans les templates Jinja, c’est bien la structure <i class="material-icons">nom_icone</i> qui est utilisée.

2. Les routes de Dashboard

La gestion des routes est répartie dans plusieurs fichiers « views ». Le fichier views.py prend en charge les routes principales de l’application. Les autres fichiers, views_history.py, views_special.py, views_demo.py, prennent respectivement en charge les pages d’historiques de données, les pages spéciales (ex. : les topics disponibles) et les pages de démo.  

Le graphe ci-dessous présente les fonctionnalités principales du tableau de bord et la définition des routes correspondantes. Cela concerne les fichiers views.py et views_history.py (affichage d’historique).

images/07RI07b.pngimages/07RI07b.png
 

Fonctionnalités et routes correspondantes

3. Accès aux données

L’accès aux données est fourni par models.py et la fonction get_db().

Selon les préceptes d’implémentation standard abordés dans le chapitre Développement web en Python, une fonction get_db() ou get_bdd() doit ressembler à ceci :

From flask import g 

 

# retrouver la Base De Données 

def get_bdd() : 

  if ’bdd’ not in g : 

     g.bdd = connecter_bdd() 

 

 return g.bdd 

 

@app.teardown_appcontext 

def teardown_app(exception): 

  bdd = g.pop( ’bdd’, None ) 

  if bdd : 

     fermer_bdd( bdd ) 

 

  # D’anciennes implémentations de Flask ne disposent 

  # pas encore de g.pop(...), il faut alors procéder  

  # comme suit : 

  # bdd = g.get( ’bdd’, None ) 

  # if dbb : 

  #    fermer_bdd( bdd ) 

  #    del( bdd )

Cependant, cette approche convient uniquement pour une seule base de données. Or, l’application dashboard utilise au moins deux bases de données distinctes. La fonction get_db() acceptera donc un paramètre complémentaire db_key permettant de sélectionner la base de données souhaitée.

a. La fonction get_db( db_key ) multi bases de données

Un petit retour sur le contenu du fichier de configuration s’impose avant d’entrer dans les détails de la fonction get_db( db_key ).

Pour rappel, le fichier de configuration contient les éléments suivants où il est possible d’identifier les différentes valeurs de db_key, à savoir ’db’ et ’db_pythonic’ (valeurs pouvant être communiquées à la fonction get_db( db_key )).

01: # coding: utf8  

02: import sys  

03: db=’/var/local/sqlite/dashboard.db’  

04: db_class=’DashboardDB’  

05: 

06: # Sources - les sources de données  

07: data_sources = [ ’db_pythonic’ ]  

08: 

09: # Accès au topics collectés par push-to-db.py  

10: db_pythonic=’/var/local/sqlite/pythonic.db’  

11: db_pythonic_class = ’PythonicDB’

Le fichier de configuration mentionne également la classe DBHelper à instancier. La classe DBHelper gère l’accès à la base de données et les méthodes de manipulation de données.

Ainsi, le fichier models.py expose les fonctions get_db(db_key) et get_data_sources() suivantes :

01: # coding: utf8  

02: from app import configuration  

03: from app import app  

04: from flask import g  

05: import sqlite3  

06: from datetime import datetime  

07: 

08: class GetDbError( Exception ):  

09:   pass  

10:  

11: def get_data_sources():  

12:   data_sources = getattr( configuration, ’data_sources’ )  

13:   assert data_sources, \  

14:        "No data_sources defined in the config file"  

15:   assert type( data_sources ) == list, \ 

16:     "the config file data_sources must be a list of string"  

17:   return data_sources  

18: 

19: def get_db( db_key ):  

20:   """ Récupère ou construit la classe d’accès  

21:    à la base de données. Extrait les  

22:    informations <db_key>=<fichier_db_sqlite3> et  

23:    <db_key>_class=<Nom_de_classe_a_instancier> """  

24: 

25:   if not ’dbs’ in g:  

26:     g.dbs = {}  

27: 

28:   if not( db_key in g.dbs ):  

29:     classname_key = ’%s_class’ % db_key  

30:     

31:     try:  

32:       db_filename = getattr( configuration, db_key )  

33:     except:  

34:       raise GetDbError(  

35:         ’Missing %s entry in configuration file!’ % db_key )  

36: 

37:     try:  

38:       classname = getattr( configuration, classname_key )  

39:     except:  

40:       raise GetDbError(  

41:         ’Missing %s entry in configuration file!’ % \ 

42:          classname_key )  

43:     

44:     # obtenir une référence vers la classe  

45:     try:  

46:       Class = globals()[ classname ]  

47:     except:  

48:       raise GetDbError(  

49:          ’class %s does not exists!’ % classname )  

50:     # instancier un objet de la classe  

51:     db_helper = Class( db_filename )  

52:     g.dbs[ db_key ] = db_helper  

53:   else:  

54:     db_helper = g.dbs[ db_key ]  

55:  

56:   return db_helper

 

La fonction get_data_sources() retourne une liste permettant de remplir le champ <select> de la « source de données » lors de l’ajout d’un bloc dans un tableau de bord (cf. Présentation dans ce chapitre).  

Obtenir une référence vers une base de données est aussi simple que :

# Base de données de configuration des 

# tableaux de bord. 

db_dashboard = get_db( ’db’ ) 

print( type( db_dashboard )) # affiche <class ’DashboardDB’> 

db_mqtt = get_db( ’db_pythonic’ ) 

print( type( db_dashboard )) # affiche <class ’PythonicDB’>

Notes d’autocritique

Une autocritique s’impose sur les différents éléments abordés dans get_db().

Premièrement, les classes DBHelper que sont PythonicDB et DashboardDB devraient hériter d’un ancêtre commun pour clairement faire état de la parenté des deux classes. En effet, toute modification des paramètres d’initialisation d’une des classes DBHelper impose la même modification dans toutes les autres classes DBHelper. Seul l’héritage permettrait de mettre ce point rapidement en évidence, ce qui peut éviter une fastidieuse session débogage durant la maintenance.

Deuxièmement, le passage du paramètre db_filename restreint le champ d’application aux sources de données constituées par un fichier physique (sans mot de passe, sans login, sans paramètres complémentaires). Il serait plus opportun de passer un dictionnaire contenant des paramètres nommés, ce qui ouvrirait la porte à des paramètres supplémentaires et donc à d’autres sources de données (ex. : une base de données PostgreSql sur un serveur distant).

b. Les classes DBHelper de Dashboard

La fonction get_db() ne retourne pas une référence vers un objet de base de données (une connexion), mais vers un DBHelper.

Ces classes DBHelper que sont DashboardDB et PythonicDB sont conçues de sorte à :

Le diagramme de classes suivant indique les services principaux offerts par les classes DashboardDB, permettant de manipuler la configuration des tableaux de bord, et PythonicDB, permettant d’obtenir les données MQTT stockées par push-to-db.py.

images/07RI39.pngimages/07RI39.png
 

Diagramme des classes DBHelper (réalisé avec draw.io)

Comme indiqué dans le diagramme, la connexion Sqlite3 initialise le row_factory à sqlite3.Row, ce qui permet d’accéder aux colonnes en utilisant la notation ligne[’nom_de_colonne’] pour obtenir la valeur d’un champ.

Dans les types retournés, la notation « [ sqlite3.Row ] » correspond à une liste d’objets sqlite3.Row.

Le code ci-dessous reprend la création de la classe PythonicDB.

01: class PythonicDB( object ):  

02:     """ classe DBHelper pour "" accès facile aux données de  

03:         push-to-db.py """  

04:     _db = None  

05:  

06:     def __init__( self, db_filename ):  

07:          self._db = sqlite3.connect( db_filename )  

08:          self._db.row_factory = sqlite3.Row  

09:  

10:     def __del__( self ):  

11:         self.close()  

12:  

13:     def close( self ):  

14:         if self._db :  

15:             self._db.close()  

16:             del(self._db)  

17:             self._db = None

Classe PythonicDB

La classe PythonicDB offre les méthodes suivantes :

topics()

Retourne la liste des topics disponibles dans la base de données. Chaque topic est accompagné du champ tsname indiquant si un historique est disponible (et dans quelle table).

get_values( topic_list )

Retourne une liste d’enregistrements topic, message, tsname, rectime correspondant à la liste des topics passés en paramètres. Si topic_list = None, alors tous les topics sont retournés.

get_history( tsname, topic, from_id = None, _len=50 )

Sélectionne une série d’enregistrements depuis la table d’historique tsname pour le topic mentionné. Les enregistrements sont retournés par ordre descendant d’id (donc le dernier en premier). Le paramètre from_id indique à partir de quel enregistrement il faut débuter la collecte des enregistrements (from_id=None pour le dernier enregistrement). Le paramètre _len indique le nombre d’enregistrements à collecter.

Classe DashboardDB

La classe DashboardDB offre les méthodes permettant de gérer la configuration des tableaux de bord.

application()

Retourne un enregistrement avec les paramètres de l’application stockés dans la base de données.

dashes()

Retourne une liste des tableaux de bord contenant les paramètres de ceux-ci. Le résultat de cette fonction permet de produire le rendu de l’écran d’accueil de l’application.

get_dash( id )

Obtient les paramètres de configuration d’un tableau de bord donné. Cette fonction retourne un enregistrement.

save_dash( **kwarg )

Sauve les paramètres d’un tableau de bord dans la base de données. Voir le détail de save_dash_block() ci-dessous concernant le paramètre **kwarg.

drop_dash( id )

Efface un tableau de bord de la base de données (y compris tous les blocs qu’il contient).

get_dash_blocks( dash_id )

Permet d’obtenir une liste d’enregistrements énumérant tous les blocs (et leurs paramètres) contenus dans le tableau de bord dash_id.

get_dash_block( block_id )

Permet d’obtenir les informations de configuration d’un bloc particulier. Retourne un enregistrement avec toutes les informations du bloc (y compris le dash_id du tableau de bord auquel il est rattaché).

save_dash_block( **kwarg )

Sauve les informations de configuration d’un bloc particulier dans la base de données. Les données sont transmises sous forme de dictionnaire.

L’exemple ci-dessous, extrait de views.py, indique comment récupérer les données depuis un formulaire HTML pour, ensuite, les sauver avec save_dash_block().

data = {  

 ’id’ : ( None if request.form[’id’] == ’’  

               else int( request.form[’id’] ) ),  

 ’dash_id’ : int( request.form[’dash_id’] ),  

 ’title’ :  request.form[’title’] ,  

 ...   

 ’hist_size’ : safe_cast( request.form[’hist_size’], int, 50 )  

}  

 

get_db(’db’).save_dash_block( **data )

Avec la formulation **data durant l’appel, le dictionnaire est passé à la fonction comme étant une série de paramètres nommés. La notation save_dash_block( **data ) est équivalente à save_dash_block( id=data[’id’], dash_id=data[’dash_id’], title=data[’title’], ... hist_size=data[’hist_size’] ).

Côté implémentation de la fonction, le paramètre **kwarg permet de récupérer tous les paramètres nommés sous forme de dictionnaire. Cette notation permet de récupérer tous les paramètres qui ne sont pas explicitement repris dans l’en-tête de la fonction. Ainsi, l’implémentation de la fonction def save_dash_block( **kwarg ) permet de récupérer la valeur à l’aide de kwarg[’id’].

drop_dash_block( block_id )

Efface un bloc de la base de données.

c. Exemple : liste des topics disponibles pour Dashboard

La liste des topics disponibles est un exemple simple mettant en lumière l’accès à une autre base de données.

images/07RI40.pngimages/07RI40.png
 

Affichage de la liste des topics disponibles

Desservi par une page spéciale, le contenu de la page est rendu par la route /{<string:name>} dans views_special.py.

01: db = get_db( ’db’ )  

02: application  = db.application()  

03: sources = get_data_sources()  

04: # Obtenir la première source de données  

05: if len( sources )==0:  

06:     flash( "Pas de sources renseignée dans le "+ 

              "fichier de configuration", ’error’ )  

07:     return redirect(url_for(’main’))  

08: 

09: source_db = get_db( sources[0] )  

10: topic_rows = source_db.get_values( topic_list = None )  

11: return render_template( ’special/topic_list.html’, 

                            application=application, 

                            source=sources[0], 

                            rows=topic_rows  )

d. Exemple : extraction de l’historique dans Dashboard

L’affichage de l’historique d’un topic est un autre exemple d’utilisation des services du DBHelper PythonicDB permettant l’extraction de l’historique des valeurs.

images/07RI41.pngimages/07RI41.png
 

Accéder à l’historique d’un bloc

images/07RI42.pngimages/07RI42.png
 

Affichage de l’historique

Servi par une page d’historique, le contenu de la page est rendu par la route /dashboard/<int:dash_id>/block/<int:block_id>/history/<int:_from> dans views_history.py.

01: def topic_history( dash_id, block_id, _from=0, _len=None ): 

02:     dashdb = get_db( ’db’ )  

03:     dash   = dashdb.get_dash( dash_id )  

04:     block  = dashdb.get_dash_block( block_id )  

05:     topic  = block[’topic’]  

06:     hist_type = block[’hist_type’]  

07:     hist_size = _len if _len else block[’hist_size’]  

08:     db_source = get_db( block[’source’] )  

09:     values    = db_source.get_values( [topic] )  

10:     tsname    = values[0][’tsname’]  

11:     hist_rows = db_source.get_history(  

12:                   tsname=tsname, topic=topic,  

13:                   from_id = None if _from <= 1 else _from,  

14:                   _len=hist_size )  

15:     if hist_type==’LIST’:  

16:         return render_template( ’history/list.html’, 

17:                block=block, dash=dash, rows=hist_rows, 

18:                _from=_from, _len=hist_size )  

19:     else:  

20:         flash( (’Type d’’historique %s non supporté’ % \ 

21:                    hist_type).decode(’utf-8’), ’error’ )  

22:         return redirect( url_for(’dashboard’, id=dash_id) )

Pour rappel, l’accès aux données d’historique depuis un bloc (table dash_block) suit le parcours suivant :

images/07RI43.pngimages/07RI43.png
 

Accès aux données d’historiques d’un topic

e. Affichage d’un tableau de bord

L’affichage d’un tableau de bord par le template dashboard.html est un cas typique d’accès à plusieurs sources de données et de la fusion de celles-ci. Des informations sont collectées aussi bien dans la base de données de configuration (dashboard.db) que dans la base de données des messages MQTT (pythonic.db).

Pour rappel, l’affichage des données dans un tableau de bord suit le flux suivant :

images/07RI02.pngimages/07RI02.png
 

Flux des données pour afficher un tableau de bord

Il y a donc deux ensembles de données à traiter pour réaliser le rendu de la page d’un tableau de bord. Identifions-les par MQTT pour pythonic.db et BLOC_CONFIG pour dashboard.db.

Deux approches techniques sont envisageables pour produire le rendu HTML :

Évaluation de l’option 1

Dans la première option, le template doit localiser et récupérer les données dans MQTT pour chacun des éléments de BLOC_CONFIG à afficher. Cela conduit à une itération en deux niveaux dans le template.

Le code du template serait inévitablement plus complexe, plus lent et donc plus difficile à maintenir. Pour rappel, le but d’un template est de réaliser un rendu, pas de réaliser une jointure entre MQTT et BLOC_CONFIG.

Évaluation de l’option 2

Réaliser une fusion des données dans le code Python est plus simple à écrire (pas de balisage Jinja) et plus rapide en termes d’exécution.

Le template Jinja dispose d’une structure alliant « configuration du bloc à afficher » + « données à afficher par ce bloc », ce qui permet au template de se concentrer sur sa tâche, le rendu du contenu.

Il est évident que c’est cette deuxième option qui doit prévaloir.

Classe BlockWithData

La fusion des données est réalisée directement dans la fonction de traitement de la route /dashboard/<int:id> (voir la fonction dashboard() dans views.py).

Le processus de fusion s’appuie sur une classe BlockWithData qui embarque à la fois la définition du bloc et la donnée du bloc.

class BlockWithData( object ):  

   block = None  

   block_data = None 

   block_config = None 

 

   def __init__( self, block, block_data, block_config ):  

       self.block = block  

       self.block_data = block_data  

       self.block_config = block_config 

 

   def __repr__( self ):  

       return ’<block: %r, block_data: %r>’ % (self.block, self.block_data)

Sans surprise, la classe embarque deux membres qui sont :

images/07RI44.pngimages/07RI44.png
 

Définition d’un bloc

{’id’: block_id,   

’topic’:_row[’topic’],  

’value’:_row[’message’],  

’history’:_row[’tsname’],  

’rectime’: db.str_to_datetime( _row[’rectime’] ),   

’tsname’ :_row[’tsname’] }

 

L’utilisation d’un dictionnaire pour le membre block_data permet de maintenir une syntaxe identique pour accéder aux données de block et aux données de block_data.

Le tableau de bord est rendu avec le template Jinja en utilisant une liste de BlockWithData, liste que l’on peut représenter comme suit block_with_data_list = [ <BlockWithData >, <BlockWithData >, <BlockWithData > ].

Ainsi, si _bloc = block_with_data_list[0], il est possible d’accéder aux informations en suivant la syntaxe mentionnée ci-dessous, syntaxe également utilisable dans le template.

titre = _bloc.block[’title’] 

couleurfond = _bloc.block[’color’] 

couleurtexte = _bloc.block[’color_text’] 

icon = _bloc.block[’icon’] 

topic = _bloc.block[’topic’] # topic a afficher 

message = _bloc.block_data[’value’] 

heure_enregistrement = _bloc.block_data[’rectime’] 

table_d_historique = _bloc.block_data[’tsname’] # ou ’history’

La fonction de traitement

La fonction de traitement dashboard() contient le code suivant :

01: @app.route(’/dashboard/<int:id>’)  

02: def dashboard( id ):  

03:    def distinct( lst ):  

04:       r = []  

05:       for item in lst:  

06:           if item in r:  

07:             continue  

08:          r.append( item )  

09:       return r  

10:  

11:    class BlockWithData( object ):  

12:       block = None  

13:       block_data = None  

14:       block_config = None 

15:       def __init__( self, block, block_data, block_config ):  

16:          self.block = block  

17:          self.block_data = block_data  

18:          self.block_config = block_config 

19:       def __repr__( self ):  

20:          return ’<block: %r, block_data: %r>’ % \  

21:                  (self.block, self.block_data)  

22:  

23:    db = get_db(’db’)         

24:    application = db.application()  

25:    dashboard   = db.get_dash( id )  

26:    block_list  = db.get_dash_blocks( id )  

27:  

28:    _source_topics  = \  

29:      [ (block[’id’], block[’source’], block[’topic’]) \  

30:        for block in block_list ]  

31:    _block_data  = {}  

32:    for source in distinct([ itm[1] for itm in _source_topics ]):  

33:       _topics = []  

34:       for id, source, topic in \  

35:             [ _source_topic for _source_topic in \  

36:               _source_topics if _source_topic[1] == source ]:  

37:          _topics.append( topic )  

38:       source_db = get_db( source )  

39:       _rows = source_db.get_values( _topics )  

40:       for _row in _rows:  

41:          block_ids = \ 

42:             [ source_topic[0] for source_topic in \  

43:               _source_topics \ 

44:               if source_topic[2] == _row[’topic’] ]  

45:          for block_id in block_ids:  

46:              _block_data[block_id] = {’id’: block_id,  

47:                   ’topic’:_row[’topic’],  

48:                   ’value’:_row[’message’],  

49:                   ’history’:_row[’tsname’],  

50:                   ’rectime’: \ 

51:                        db.str_to_datetime( _row[’rectime’] ),  

52:                   ’tsname’ :_row[’tsname’] }  

53:  

54:    block_with_data_list = []  

55:    for row in block_list:  

56:       try:  

57:          _block_config = {} if \  

58:               row[’block_config’]==None or  \ 

59:               len( row[’block_config’] )==0 \  

60:             else json.loads( row[’block_config’] )  

61:       except Exception as err:  

62:          app.logger.error(  

63:    ’Failed to convert JSON block_config for block %s ’+ 

64:    ’to Python structure.’ % row[’id’] )  

65:          app.logger.error( ’due to error %s on %s’ %  

66:                            (err,row[’block_config’]) )  

67:          _block_config = { ’_error’ :  

68:    [’unable to convert json block_config for block %s ’+ 

69:     ’to python structure’% row[id],  

70:     ’due to error %s on %s’ % (err,row[’block_config’]) ]  

71:                          } 

72:       block_with_data_list.append(  

73:         BlockWithData(  

74:             block=row, 

75:             block_data=_block_data[row[’id’]], 

76:             block_config=_block_config 

76:          )  

77:       )  

78:  

79:    return render_template( ’dashboard.html’,  

80:          block_with_data_list = block_with_data_list,  

81:          dashboard  = dashboard,  

82:          application= application,  

83:          configuration=configuration )

Le template Jinja

Les détails du rendu réalisé par le template dashboard.html sont abordés dans la section concernant les macros Jinja. En effet, le rendu des blocs d’un tableau de bord s’appuie sur celles-ci.

4. Les filtres Jinja personnalisés

Le projet Dashboard inclut deux filtres Jinja personnalisés.

Le premier filtre, déjà abordé lors de la description du template de base, est special_page. Le filtre special_page extrait le texte placé entre des balises « {} » et renvoie la valeur en majuscules. S’il n’y a rien à extraire, alors le filtre retourne None.

Le filtre peut s’utiliser dans un template Jinja comme suit :

{% set url = url_for(’special_page’, name=r[’label’] | special_page ) %}

Le second filtre est strftime( format=’hour’, source_type=’datetime’ ) qui permet d’appliquer un format particulier à une donnée de type datetime. Par défaut, le filtre utilise la source_type=’datetime’ lorsque la valeur est un objet datetime mais il peut aussi prendre en charge le source_type=’sqlite_dt’ lorsque la donnée est fournie par le moteur SQLite (donc au format « Année-mois-jour Heure:Minutes:Secondes. millisecondes »). Le format permet de préciser le type de transformation souhaitée. Le paramètre format=’hour’ affiche l’information au format « Heure:Minutes » tandis que format=’full’ utilise le format « jour/mois/année heure:minutes:secondes ». Finalement, format=’elapse’ indique le temps écoulé depuis la date avec le motif « m mois, j jours, h heures, m minutes, s secondes » (en ne précisant que l’information pertinente).  

<td>{{ row[’rectime’] | strftime(format="full", source_type="sqlite_dt") }}</td> 

 

<td>{{ row[’rectime’] | strftime( format="elapse", source_type="sqlite_dt" ) }}

</td></tr>

Les filtres personnalisés sont définis dans le fichier views.py et déclarés à l’aide du décorateur @app.template_filter().

01: @app.template_filter(’strftime’)  

02: def strftime_filter(value, format=’hour’,  

03:                     source_type=’datetime’):  

04:    if source_type==’datetime’:  

05:       if type(value)!=datetime:  

06:          return "(value is not a datetime!)"  

07:    elif source_type==’sqlite_dt’:  

08:       if type(value)!=unicode:  

09:          return "(value is not unicode!)"  

10:       try:  

11:          value = datetime.strptime( value ,  

12:             ’%Y-%m-%d %H:%M:%S.%f’ )  

13:       except:  

14:          return "(%s invalid format!)" % value  

15:  

16:    if format == ’hour’:  

17:       _format="%H:%M"  

18:    elif format == ’full’:  

19:       _format="%d/%m/%Y %H:%M:%S"  

20:    elif format == ’elapse’:  

21:       sec = (datetime.now()-value).seconds  

22:       min = sec / 60  

23:       sec = sec % 60  

24:       hour = min / 60  

25:       min = min % 60  

26:       day = (datetime.now()-value).days  

27:       month = day / 30  

28:       day = day % 30  

29:       if (min>0) or (hour>0) or (day>0):  

30:          _lst = []  

31:          if month>0:  

32:             _lst.append( "%s mois" % month )  

33:          if day>0:  

34:             _lst.append( "%s jour%s" %  

35:                   (day, ("s" if day>0 else "")) )  

36:          if hour>0:  

37:             _lst.append( "%s heure%s" %  

38:                   (hour, ("s" if hour>0 else "")) )  

39:          if min>0:  

40:             _lst.append( "%s minute%s" %  

41:                   (min, ("s" if min>0 else "")) )  

42:  

43:          return ", ".join( _lst )  

44:       else:  

45:          return ’%s secondes’ % sec  

56:    else:  

 

47:       _format = format  

48: 

49:    try:  

50:       return value.strftime(_format)  

51:    except Exception as err:  

52:       app.logger.error( "format_strftime: unable "+ 

52:             "to convert datetime %s with "+ 

54:             "format %s due following error" % \ 

55:             (value, format) )  

56:       app.logger.exception( err )  

57:       return "(strftime formatting error!)"  

58:  

59: @app.template_filter(’special_page’)  

60: def special_page_filter(value):  

61:    if ( value.find(’{’)>=0 ) and ( value.find(’}’)>=0 ):  

62:       return \ 

63:          value[ value.find(’{’)+1 : value.find(’}’) ] \ 

64:          .upper()  

65:    else:  

66:       return None

 

Lorsqu’il y a une erreur, celle-ci est mentionnée clairement dans la valeur retournée par le filtre. Cela permet d’identifier dans la page rendue qu’il y a un problème avec les données transmises au filtre.

5. Affichage du tableau de bord

L’affichage du tableau de bord s’appuie sur le template dashboard.html. Héritant de la page de base, il définit les différents blocs Jinja dont le bloc content et le bloc onDocumentReady.

Le fonctionnement du template est très basique, il parcourt la collection de block_with_data (préparée par la route ’/dashboard/<int:id>’) pour générer la structure HTML des blocs dans la page.

Pour s’aider dans cette tâche, le template s’appuie sur une série de macros développées dans /template/macro/block.macro. Ces macros prennent en charge l’affichage du contenu de chaque type de bloc.

images/07RI45.pngimages/07RI45.png
 

Affichage du contenu d’un dashboard

Le template est appelé depuis views.py avec l’instruction suivante :

    return render_template( ’dashboard.html’,  

       block_with_data_list = block_with_data_list,  

       dashboard  = dashboard,  

       application= application,  

       configuration=configuration,  

       mqtt_sources=mqtt_sources )

où :

Le code du template dashboard.html est relativement concis.

01: {% extends "base.html" %}  

02: {% import "macro/block.macro" as Block %}  

03: {% set title = dashboard.label %}  

04: {% set title_url = url_for(’dashboard’, id=dashboard.id ) %}  

05: {% set dash_color = dashboard.color %}  

06: {% block actions %}  

07:     <li><a href="{{  

08:            url_for(’block_list’, dash_id = dashboard.id)  

09:            }}">  

10:            <i class="material-icons">build</i>  

11:         </a>  

12:     </li>  

13:     <li>  

14:         <a href="{{  

15:            url_for(’block_add’, dash_id = dashboard.id)  

16:            }}">  

17:            <i class="material-icons">add</i>  

18:         </a>  

19:     </li>  

20:     <li>  

21:         <a href="{{  

22:            url_for(’dashboard’, id = dashboard.id)  

23:            }}">  

24:            <i class="material-icons">refresh</i>  

25:         </a>  

26:     </li>  

27: {% endblock %}  

28: {% block content %}  

29: <div class="row">  

30:     {% if block_with_data_list | length <= 0 %}  

31:     <div class="col s12">  

32:       <div class="amber lighten-3">  

33:         <div class="card-content black-text">  

34:         <span class="card-title">  

35:              <strong>Truc et astuce</strong> 

36:         </span>  

36:         <p>Il n’y a pas encore de bloc dans le  

37:                 dashboard.<br />Cliquer sur l’icône  

38:                 ’+’ en haut à droite pour ajouter un  

39:                 premier bloc.<br/>  

40:         </p>  

42:         </div>  

43:       </div>  

44:     </div>     

45:     {% endif %}  

46:     

47:     {% for bwd in block_with_data_list %}  

48:        <div id="an_id_block"  

49: class="{{ bwd.block[’color’] }} center blocs col s12 m4 l3 ">  

50:           {#- bwd = Block With Data -#}  

51:           {%- if bwd.block_data[’tsname’] -%}  

52:                 <a href="{{  

53:                url_for( ’topic_history’,  

54:                dash_id=dashboard.id,  

55:                block_id=bwd.block[’id’],  

56:                _from=0, _len=bwd.block[’hist_size’] )  

57:                }}">  

58:                <span class="new badge black white-text"  

59:                      data-badge-caption="">  

60:                   <i class="material-icons">zoom_in</i>  

61:                </span>  

62:           </a>  

63:           {%- endif -%}  

64:           {{ Block.make_block(  

65:                   bwd.block[’block_type’],  

66:                   bwd.block[’id’],  

67:                   bwd.block[’title’],  

68:                   bwd.block_data[’value’],  

69:                   bwd.block_config,  

70:                   {’color’:bwd.block[’color’],  

71:                    ’color_text’:bwd.block[’color_text’],  

72:                    ’icon’:bwd.block[’icon’],  

73:                    ’rectime’:bwd.block_data[’rectime’]  

74:                   }  

75:              ) | safe }}  

76:        </div>  

77:     {% endfor %}  

78:  

79: </div>  

80: {% endblock %}  

81: {% block onDocumentReady %}  

82:    setTimeout( function(){  

83:        window.location.reload();  

84:       }, {{ configuration.refresh_time*1000 }} );  

85: 

86:    block_configs = {}; 

87:    {% for bwd in block_with_data_list %} 

88:       {% if bwd.block[’block_config’] %} 

89:           block_configs[’{{ bwd.block[’id’] }}’] = 

90:   JSON.parse( {{ bwd.block[’block_config’] | tojson }} ); 

91:       {% else %} 

92:           block_configs[’{{ bwd.block[’id’] }}’] = undefined; 

93:       {% endif %} 

94:    {% endfor %} 

95: 

97:    mqtt_sources = {{ mqtt_sources | tojson }}; 

99: {% endblock %}

Pour rappel, la structure Python mqtt_sources est un dictionnaire produit à partir du fichier de configuration à l’aide de la fonction get_mqtt_sources( as_dict=True ) de models.py.

6. Les macros Jinja

Le projet Dashboard prévoit deux séries de macros Jinja :

a. La macro make_block

La macro make_block est utilisée pour réaliser le rendu de l’information télémétrique dans un bloc du dashboard entre les éléments <div> et </div>.

Cette macro est le point d’entrée principal pour tous les blocs affichés et délègue le rendu final du bloc à d’autres macros en fonction du type de bloc à afficher (icon, big_text, switch).

Signature de la macro

{% macro make_block( block_type, id, title, label, block_config, params ) %}

Avec les paramètres :

Appel de la macro

L’appel de la macro depuis le template dashboard.html, repris ci-dessous, met en évidence l’extraction des différents paramètres passés à la macro :

64:           {{ Block.make_block( 

65:                   bwd.block[’block_type’], 

66:                   bwd.block[’id’], 

67:                   bwd.block[’title’], 

68:                   bwd.block_data[’value’], 

69:                   bwd.block_config,  

70:                   {’color’:bwd.block[’color’],  

71:                    ’color_text’:bwd.block[’color_text’], 

72:                    ’icon’:bwd.block[’icon’],  

73:                    ’rectime’:bwd.block_data[’rectime’] 

74:                   }  

75:              ) | safe }}

Les informations extraites avec bwd.block[] font références à la configuration du bloc (donc la table dash_blocks dans la base de données dashboard.db).

Les informations extraites avec bwd.block_data[] font référence aux données télémétriques collectées (donc la table topicmsg dans la base de données pythonic.db).

Les paramètres de configuration du bloc (au format JSON, saisis par l’utilisateur) sont disponibles sous forme d’objet Python.

Les lignes 70 et 74 : création d’un dictionnaire de paramètres avec les paramètres de rendu de bloc.

Définition de make_block()

La macro make_block() ne fait rien de particulièrement exotique. Elle se contente de vérifier que le paramètre block_type contient une valeur connue, puis délègue l’exécution aux macros block_icon(), block_big_text(), etc.

 

Lorsqu’un nouveau type de bloc est ajouté, un nouveau branchement vers une macro block_nouveau_bloc_type() est ajouté dans la macro make_block().

01: {% set block_types = [’icon’, ’big_text’] %} 

02: 

03: {% macro make_block( block_type, id, title, label,  

04:                      config, params ) %}  

05:   {% if not(block_type in block_types) %}  

06:        {{ block_error( "Error",  

07:              "’%s’ block_type is not registered in the 

08:                block_types Jinja variable" % block_type,  

09:              None )  

10:        }}  

11:   {% elif block_type == ’icon’ %}  

12:       {{ block_icon( id, title, label, config, 

                        params ) }}  

13:   {% elif block_type == ’big_text’ %}  

14:       {{ block_big_text( id, title, label, config,  

                            params ) }}  

15:   {% else %}  

16:        {{ block_error( id, "Error",  

17:              "make_block Jinja macro does not know how  

18:               to handle block_type="+block_type,  

19:           config, None ) }}  

20:   {% endif %}  

21: {% endmacro %}

b. La macro block_icon

Cette macro effectue le rendu d’un bloc contenant une icône et affiche le libellé (donnée télémétrique) sous l’icône.

images/07RI19.pngimages/07RI19.png
 

Bloc ICON tel que défini en début de chapitre

Le bloc est généré à l’aide de la macro block_icon().

01: {% macro block_icon( id, title, label, config, params ) %}  

02:   <h4 class="{{ params[’color_text’] }}-text"> 

03:     {{ title }} 

04:   </h4>  

05:   <i class="material-icons {{ params[’color_text’] }}-text"

06:          style="font-size:6rem;"> 

07:          {{ params[’icon’] }} 

08:   </i>  

09:   <br>  

10:   <h5 class="{{ params[’color_text’] }}-text"> 

11:     {{ label }} 

12:   </h5>  

13:  {% if params[’rectime’] %}  

14:     <p id="{{ id }}_rectime_footer"  

15:        class="foot {{ params[’color_text’] }}-text"> 

16:           {{ params[’rectime’] | strftime("elapse") }} 

17:     </p>  

18:  {% endif %}  

19: {% endmacro %}

c. La macro block_big_text

Cette macro fait le rendu d’un bloc contenant uniquement du texte, la donnée télémétrique au centre du bloc.

images/07RI18.pngimages/07RI18.png
 

Bloc BIG_TEXT tel que défini dans le chapitre

Le bloc est généré à l’aide de la macro block_big_text().

01: {% macro block_big_text( id, title, label,  

02:                          config, params ) %}  

03:   <h4 class="{{ params[’color_text’] }}-text"> 

04:      {{ title }} 

05:   <h4>  

06:   <h2 class="{{ params[’color_text’] }}-text"> 

07:      {{ label }} 

08:   </h2>  

09:   <p id="{{ id }}_footer"  

10:      class="foot class="{{ params[’color_text’] }}-text"> 

11:      {{ params[’footer_text’] }} 

12:   </p>  

13:   {% if params[’rectime’] %}  

14:      <p id="{{ id }}_rectime_footer"  

15:         class="foot {{ params[’color_text’] }}-text"> 

16:         {{ params[’rectime’] | strftime("elapse") }} 

17:      </p>  

18:   {% endif %}  

19: {% endmacro %}

d. La macro select_color (édition d’un bloc)

La page d’édition d’un bloc utilise des macros (provenant de /templates/macro/M_input.macro) pour afficher les listes de sélection <select> Materialize pour la couleur, le type de bloc et l’icône.

images/07RI09.pngimages/07RI09.png
 

Page d’édition d’un bloc

Les différents <select> Materialize sont pris en charge par les macros Jinja :

Où name est le nom du champ <select> Materialize dans le formulaire et current la valeur actuelle du champ <select>.

Par exemple, la macro select_color() permet de générer le <select> Materialize pour la sélection de couleurs, comme présenté ci-dessous.

images/07RI10.pngimages/07RI10.png
 

Sélection de couleur

La macro select_color() est codée comme suit :

01: {% macro select_color( name, current=None ) %}  

02:     {% set colors=[(’rouge’,’red’),(’rose’,’pink’), 

03:          (’pourpre’,’purple’),(’pourpre foncé’,  

04:          ’deep-purple’), (’indigo’,’indigo’),   

05:          (’bleu’, ’blue’), (’bleu léger’, ’light-blue’), 

06:          ... 

07:          (’gris’, ’grey’), (’gris bleu’, ’blue-grey’), 

08:          (’noir’,’black’), (’blanc’,’white’)] %}  

09:     <select id="{{ name }}" name="{{ name }}" class="icons">  

10:         <option value="" disabled  

11:             {% if current == None %}selected{% endif %}> 

12:             Choisir une couleur 

13:         </option>  

14:         {% for color in colors %}  

15:         <option value="{{ color[1] }}"  

16:             data-icon="{{ url_for( ’static’, 

17:             filename=’images/colors/’+color[1]+’.png’) }}"  

18:             {% if current == color[1] %}selected{% endif %} 

19:             > {{ color[0] }} </option>  

20:         {% endfor %}  

21:     </select>  

22: {% endmacro %}

Utilisation de la macro

La macro select_color présentée ci-dessus est utilisée dans la page d’édition d’un bloc de tableau de bord. Cette page est prise en charge par le template bloc_edit.html dont une partie du code est repris ci-dessous :

01: {% extends "base.html" %}  

02: {% import "macro/M_input.macro" as M_input %}  

03: {% import "macro/block.macro" as Block %}  

04: {% if row[’id’] | default("", true) == "" %}  

05:    {% set title = "Nouveau Bloc" %}  

06: {% else %}  

07:    {% set title = "Modifier Bloc" %}  

08: {% endif %}  

09: {% set dash_color = dashboard.color %}  

10: {% block actions %}  

11: {% endblock %}  

12: {% block content %}  

13: <div class="row">  

14:     <div class="row">  

15:         <form action=" 

16:     {{ url_for(’block_add’, dash_id = row[’dash_id’]) }}" 

17:               method="post" class="col s12">  

18:             ... 

19:             <div class="row">  

20:                 <div class="input-field col s12">  

21:         {{ M_input.select_block_type( "block_type", 

22:                     current=row[’block_type’] ) | safe }}  

23:                    <label>Type de bloc</label>  

24:                 </div>  

25:             </div>  

26:             <div class="row">    

27:                 <div class="input-field col s12">    

28:         {{ M_input.select_color( "color",  

29:                     current=row[’color’] ) | safe }}  

30:                     <label>Couleur du fond</label>  

31:                 </div>  

32:             </div>  

33:             <div class="row">                      

34:                 <div class="input-field col s12">  

35:         {{ M_input.select_color( "colortext",  

36:                     current=row[’color_text’] ) | safe }}  

37:                     <label>Couleur du texte</label>  

38:                 </div>  

39:             </div>  

40:             <div class="row">  

41:                 <div class="input-field col s12">  

42:         {{ M_input.select_icon( "icon",  

43:                     current=row[’icon’] ) | safe }}  

44:                     <label>Icon</label>  

45:                 </div>  

46:             </div> 

47:             {# Submit button #}  

48:             <div class="row">  

49:        <button class="btn waves-effect waves-light red right" 

50:                type="submit" name="action"  

51:                value="cancel">Abandonner  

52:                     <i class="material-icons right">close</i>  

53:        </button>  

54:        <button  

55:            class="btn waves-effect waves-light green right"  

56:            type="submit" name="action"  

57:            onclick="return checkSubmit();" 

58:            value="submit">Sauver  

59:                    <i class="material-icons right">check</i>  

60:        </button>         

61:            </div>  

62:  

63:         </form>             

64:    </div>  

65: </div>  

66: {% endblock %} 

67: {% block javascript %} 

68:    ... 

69: function checkSubmit(){  

70:   var bError = false;  

71:   if ( $("#title").val().trim().length == 0 ){  

72:     M.toast( { html: ’Le bloc doit avoir un titre’,  

73:                classes : ’red’ } );  

74:     bError = true;  

75:   }  

76:   if ( !( $("#block_type").val() ) ){  

77:     M.toast( { html: ’Le bloc doit avoir un type’,  

78:                classes : ’red’ } );  

79:     bError = true;  

80:   }  

81:   if ( !( $("#source").val() ) ){  

82:     M.toast( {  

83:            html: ’Le bloc doit avoir une source de donnée’, 

84:            classes : ’red’ } );  

85:     bError = true;  

86:   }  

87:   if ( !( $("#topic").val() ) ){  

88:     M.toast( { html: ’Le bloc doit avoir un topic’,  

89:                classes : ’red’ } );  

90:     bError = true;  

91:   }  

92: 

93:   return !(bError);  

94: }     

95: {% endblock %}

La description ci-dessous reprend uniquement les éléments pertinents.

images/07RI57.pngimages/07RI57.png
 

Mise à jour de l’information du bloc après recapture de l’état par push-to-db

<select id="color" name="color" class="icons">  

  <option value="" disabled > 

      Choisir une couleur 

  </option>  

  <option value="red"  

      data-icon="/static/images/colors/red.png" > 

      rouge 

  </option>  

  <option value="pink"  

      data-icon="/static/images/colors/pink.png" selected

      rose 

  </option>  

  <option value="purple"  

      data-icon="/static/images/colors/purple.png" >  

      >pourpre</option>  

  <option value="deep-purple"  

      data-icon="/static/images/colors/deep-purple.png"  

      >pourpre foncé</option>  

  <option value="indigo"  

      data-icon="/static/images/colors/indigo.png"  

      >indigo</option>  

</select>

Bloc switch (marche/arrêt)

Cette partie du chapitre décrit toutes les étapes nécessaires à l’ajout d’un nouveau bloc. Le bloc SWITCH.

images/07RI29.pngimages/07RI29.png
 

Le bloc SWITCH

Le bloc SWITCH dispose d’une nouvelle fonctionnalité importante par rapport aux autres blocs : il est destiné à activer un élément distant comme le relais branché sur l’objet chaufferie (cf. Les objets ESP8266 - Objet 4 : Chaufferie).

images/04RI93.pngimages/04RI93.png
 

Objet chaufferie

Pour rappel, l’objet chaufferie :

Dashboard - fonctionnalités disponibles et manquantes

Le bloc SWITCH doit donc afficher l’état du bouton en fonction des états MARCHE/ARRET présents sur le topic maison/cave/chaufferie/etat, ce qui reste dans les possibilités de l’implémentation actuelle.

Par contre, le bloc SWITCH doit également être capable de réaliser une publication MQTT vers le topic maison/cave/chaufferie/cmd pour modifier l’état de l’objet. Idéalement, le bloc devrait faire directement une souscription sur le topic maison/cave/chaufferie/etat pour réaliser une mise à jour du bloc.

Le schéma de fonctionnement général du projet est donc modifié pour ajouter la fonctionnalité de publication/souscription MQTT directement depuis le navigateur Internet.

images/07RI46.pngimages/07RI46.png
 

Le bloc SWITCH et la publication MQTT en JavaScript

Dashboard - développements à prévoir

Hormis les modifications nécessaires pour l’ajout d’un nouveau bloc, il est évident que le projet Dashboard doit disposer d’un paramétrage complémentaire (pour les paramètres MQTT du bloc et les sources MQTT), ainsi que l’exploitation de MQTT depuis une page HTML (grâce à Paho JavaScript : https://www.eclipse.org/paho/clients/js/)

1. Développements complémentaires

Quelques développements complémentaires sont nécessaires dans le projet Dashboard pour supporter des paramètres complémentaires pour les blocs et la mise à disposition de sources MQTT depuis le fichier de configuration (comme les sources issues de la base de données, mais avec des serveurs MQTT au lieu de bases de données).

a. MQTT sources

Fichier de configuration

Tout comme pour les sources issues de bases de données, le fichier de configuration (app/dashboard.cfg.default) contient des sources MQTT.

# Sources - the MQTT sources  

#   allows block to interact with mqtt servers  

mqtt_sources = [ ’mqtt_pythonic’ ]  

 

mqtt_pythonic = ’pythonic.local’ # Mqtt server  

mqtt_pythonic_port = 1883  

mqtt_pythonic_username = ’pusr103’  

mqtt_pythonic_passwd   = ’21052017’

La variable mqtt_sources contient une liste des sources MQTT disponibles. Dans le cas présent, il s’agit d’une liste avec une unique entrée « MQTT source » valant ’mqtt_pythonic’.

Pour chaque entrée dans la liste, ’mqtt_pythonic’ par exemple, il doit y avoir quatre variables :

models.py

Le fichier models.py contient une nouvelle fonction get_mqtt_sources() pour les serveurs MQTT (similaire à get_data_sources() pour les bases de données).

01: def get_mqtt_sources( as_dict = False ):  

02: 

03:   def extract_config( key, default=’_’ ):  

04:      """ Extrait une valeur nommée depuis la configuration """  

05:      try:  

06:         _result = getattr( configuration, key )  

07:      except:  

08:         if default==’_’:  

09:            raise GetDbError(  

10:             ’Missing %s entry in configuration file!’ % key )  

11:         else:  

12:           _result = default  

13:      return _result  

14:  

15:   mqtt_sources = getattr( configuration, ’mqtt_sources’ )  

16:   if not( mqtt_sources ):  

17:      mqtt_sources =  []   

18:   assert type( mqtt_sources ) == list, \  

19:      "the config file mqtt_sources must be a list of string"  

20:  

21:   if not( as_dict ):  

22:      return mqtt_sources  

23:   else:  

24:      _dic = {}  

25:      for _source in mqtt_sources:  

26:         _params = {}  

27:         _params[’server’]  = \ 

28:             extract_config( _source )  

29:         _params[’port’]    = \ 

30:             extract_config( ’%s_port’ % _source )  

31:         _params[’username’]= \ 

32:             extract_config( ’%s_username’ % _source, None )  

33:         _params[’passwd’]  = \ 

34:             extract_config( ’%s_passwd’% _source, None )  

35:         _dic[_source] = _params  

36:      return _dic

Telle qu’elle est définie, la fonction get_mqtt_sources() appelée sans paramètre retourne une simple liste des sources MQTT, soit [ ’mqtt_pythonic’ ].

La syntaxe alternative get_mqtt_sources( as_dict = True ) retourne toute l’information sous forme de dictionnaire.

Dans le cas de la définition du fichier de configuration app/dashboard.cfg.default ci-dessus, l’appel de get_mqtt_sources( as_dict=True ) retourne le dictionnaire Python suivant :

{’mqtt_pythonic’: {’passwd’: ’21052017’,  

                  ’port’: 1833,  

                  ’server’: ’pythonic.local’,  

                  ’username’: ’pusr103’}}

Exploiter la structure avec JSON

Transformer des structures Python en format JSON permet d’exploiter ces mêmes informations dans le navigateur Internet à l’aide de JavaScript.

La structure du dictionnaire peut être transformée en structure JSON à l’aide de l’instruction suivante :

import json 

from model import get_mqtt_source 

_json = json.dumps( get_mqtt_sources( as_dict = True ) )

Ce qui produit une chaîne de caractères JSON pouvant être incluse dans du code JavaScript.

’{"mqtt_pythonic": {"username": "pusr103", "passwd": "21052017",

"port": 1833, "server": "pythonic.local"}}’

En effet, comme le bloc SWITCH doit contacter directement le broker MQTT, la page doit embarquer l’information pour permettre la connexion sur les sources MQTT. Cela permettra au code JavaScript de contacter le broker MQTT depuis le navigateur internet.

Exploiter la structure avec Jinja

Le moteur de template Jinja propose également le filtre json permettant de transformer un objet Python en objet JSON directement exploitable en JavaScript.

Le template Jinja suivant :

{% block onDocumentReady %}  

 {#- Injecter définition des sources MQTT #}  

 mqtt_sources = {{ mqtt_sources | tojson }};  

 

 setTimeout(function(){  

       window.location.reload();  

 }, {{ configuration.refresh_time*1000 }} );  

{% endblock %}

Produit le contenu HTML correspondant dans la page HTML.

mqtt_sources = {"mqtt_pythonic": {"passwd": "21052017", "port": 1883,

"server": "pythonic.local", "username": "pusr103"}};  

 

setTimeout(function(){  

   window.location.reload();  

 }, 120000 );  

 });

b. Bloc et paramètres additionnels

Saisie de paramètres

Le bloc SWITCH nécessitera une série de paramètres additionnels. Le champ block_config activé dans la page de configuration du bloc permet de saisir ces informations.

Le template bloc_edit.html a été modifié pour permettre l’édition du champ block_config (anciennement un champ <input type=’hidden’> maintenant transformé en <textarea>).

images/07RI47.pngimages/07RI47.png
 

Activation de la zone d’encodage block_config

La fonction JavaScript checkSubmit() évoquée précédemment est modifiée pour vérifier le contenu du champ block_config.

01: function checkSubmit(){  

02:     var bError = false;  

03:     if ( $("#title").val().trim().length == 0 ){  

04:         M.toast( { html: ’Le bloc doit avoir un titre’, classes : ’red’ } );  

05:         bError = true;  

06:     }  

07:     ...  

08:  

09:     // check JSON  

10:     var jsontext = $(’#block_config’)[0].value;  

11:     if( jsontext.length>0 )  

12:        try {  

13:           var tojson = JSON.parse( jsontext );  

14:        }  

15:        catch( err ){  

16:          console.error( ’error parsing JSON configuration param! ’+err );  

17:          M.toast( { html: ’Configuration (JSON) incorrecte’, classes : ’red’ } ); 

18:          M.toast( { html: err, classes : ’red’ } );  

19:          bError = true;  

20:        }  

21:     console.log( tojson );  

22:  

23:     return !(bError);  

24: }

Inclusion des « block_config » dans dashboard.html

Étant donné que les définitions block_config des différents blocs peuvent embarquer des informations très utiles pour le code JavaScript de la page, ces informations sont injectées dans le template dashboard.html au niveau du bloc Jinja {% block onDocumentReady %} :

{% block onDocumentReady %} 

 block_configs = {}; 

 {#- Données JSON Block_config (dispo pour le JavaScript) #}  

 {% for bwd in block_with_data_list %}  

    {% if bwd.block[’block_config’] %}  

       block_configs[’{{ bwd.block[’id’] }}’] = 

          JSON.parse( {{ bwd.block[’block_config’] | tojson }} );  

    {% else %}  

       block_configs[’{{ bwd.block[’id’] }}’] = undefined;  

    {% endif %}  

 {% endfor %}  

 

 {#- Injecter les sources MQTT  #}  

 mqtt_sources = {{ mqtt_sources | tojson }};  

 

 setTimeout(function(){  

       window.location.reload();  

     }, {{ configuration.refresh_time*1000 }}  

 );  

{% endblock %}

Le code JavaScript crée une variable globale block_configs contenant un dictionnaire vide avec block_configs = {};

Par la suite, la boucle {% for bwd in block_with_data_list %} permet de parcourir tous les blocs et d’initialiser les différentes entrées du dictionnaire.

Si un bloc ne contient pas d’information block_config (pas de structure JSON), alors l’entrée dans le dictionnaire est composée comme ceci :

block_configs[ block_id ] = undefined ;

Si le bloc contient des données JSON dans block_config, alors l’entrée dans le dictionnaire JavaScript ressemble à ceci :

block_configs[ block_id ] =JSON.parse( "{  \"switch\": \r\n    {  

\"check\":\"MARCHE\" ,\r\n      \"uncheck\":\"ARRET\"\r\n   },\r\n  

\"action\":\r\n       { \"checked\":  { \"source\": \"mqtt_pythonic\",

\"topic\": \"maison/cave/chaufferie/cmd\", \"message\":\"MARCHE\" },

\r\n         \"unchecked\": {\"source\": \"mqtt_pythonic\", \"topic\":

\"maison/cave/chaufferie/cmd\", \"message\": \"ARRET\" }\r\n        },

\r\n   \"watch\" : \r\n     { \"source\": \"mqtt_pythonic\", \"topic\":

\"maison/cave/chaufferie/etat\" }\r\n}" );

 

Étant donné que les informations JSON sont encodées dans un champ texte réparti sur plusieurs lignes, il est difficile d’inclure le texte tel quel dans le code JavaScript. L’astuce consiste à injecter la structure JSON comme une chaîne de caractères JSON (à l’aide de la balise Jinja {{ bwd.block[’block_config’] | tojson }} ), puis de parser ladite chaîne de caractères (avec JSON.parse() ) dans le JavaScript pour recréer la structure JSON.

Inclure les « block_config » dans la structure BlockWithData

Déjà évoquée dans la section Détails de l’application Flask - Affichage du tableau de bord, la classe BlockWithData permet d’embarquer la définition de block_config sous forme d’une structure d’objet Python.

De la sorte, l’information est disponible lors du rendu du template dashboard.html et durant les appels des macros make_block() :

class BlockWithData( object ):  

   block = None  

   block_data = None 

   block_config = None

 

   def __init__( self, block, block_data, block_config ):  

       self.block = block  

       self.block_data = block_data  

       self.block_config = block_config 

 

   def __repr__( self ):  

       return ’<block: %r, block_data: %r>’ % (self.block, self.block_data)

2. Ajout du bloc SWITCH

a. Block_config du switch

Avant toute chose, il est important de déterminer les paramètres attendus dans block_config pour le switch. Les informations sont saisies au format JSON et permettent de paramétrer les blocs sans devoir altérer l’interface d’édition.

  "switch": {  

     "check":"MARCHE" , 

     "uncheck":"ARRET" 

  }, 

  "action": { 

     "checked":{  

        "source": "mqtt_pythonic",  

        "topic": "maison/cave/chaufferie/cmd",  

        "message":"MARCHE"  

     }, 

     "unchecked":{ 

        "source": "mqtt_pythonic",  

        "topic": "maison/cave/chaufferie/cmd",  

        "message": "ARRET"  

     } 

  }, 

  "watch": {  

     "source": "mqtt_pythonic",  

     "topic": "maison/cave/chaufferie/etat" } 

}

Le premier niveau contient trois clés :

 

La mention de "source" fait référence aux sources MQTT définies dans le fichier de configuration du projet Dashboard.

Lorsque le switch est placé à l’arrêt (donc la checkbox décochée), la structure permet d’obtenir aisément le topic et le message à envoyer sur le broker MQTT, et ce quel que soit le langage de programmation employé (JavaScript, Python).

En effet, il faut accéder au block_config comme suit en JavaScript :

message = block_config.uncheck.message ; 

topic = block_config.uncheck.topic ;

Ou encore comme ceci en Python (après chargement de la structure JSON) :

message = block_config[’uncheck’][’message’] 

topic = block_config[’uncheck’][’topic’]

Ou encore comme ceci dans un template Jinja (lorsque l’objet block_config Python est communiqué au template) :

{% set message = block_config.uncheck.message %} 

{% set topic = block_config.uncheck.topic %}

b. Ajouter le nouveau type de bloc

Les éléments sont maintenant en place pour ajouter un nouveau type de bloc nommé switch dans le projet.

Icône du bloc

Le nouveau type de bloc doit avoir une icône, une image PNG de 48 x 48 pixels placée dans le répertoire /app/static/images/block_types/ et nommée avec type du bloc (donc switch.jpg) :

images/07RI48.pngimages/07RI48.png
 

Icône du bloc switch dans les ressources statiques

templates/block.macro

Modifier la définition de block_types pour ajouter le type de bloc switch.

{% set block_types = [’icon’, ’big_text’, ’switch’] %}

Indiquer la dépendance à MQTT en ajoutant switch dans la définition de mqtt_required_blocks. Cela permet d’indiquer que si un des blocs de la liste est présent dans la page, alors il faut inclure les ressources MQTT.

{% set mqtt_required_blocks = [’switch’] %}

Ajouter la macro block_switch pour réaliser le rendu du bloc dans le tableau de bord.

{% macro block_switch( id, title, label, config, params ) %}  

 <h4>{{ title }}</h4>  

 <i class="material-icons"  

    style="font-size:6rem;">{{ params[’icon’] }}</i>  

 <div class="switch">  

     <label>  

         Off  

         <input type="checkbox" 

                id="checkbox_switch_{{ id }}"  

       {% if config.switch and config.switch.check == label %}  

                checked  

       {% endif %}> 

         <span class="lever"></span>  

         On  

     </label>  

 </div>  

 <p id="{{ id }}_footer"  class="foot"> 

    {{ label }} {{ params[’footer_text’] }} 

 </p>  

 {% if params[’rectime’] %}  

     <p id="{{ id }}_rectime_footer" class="foot"> 

       {{ params[’rectime’] | strftime("elapse") }} 

     </p>  

 {% endif %}  

{% endmacro %}

Le champ <input type="checkbox"> porte un id spécifique commençant par "checkbox_switch_" et incluant ensuite l’id du bloc.

L’instruction {% if config.switch and config.switch.check == label %} permet de comparer la valeur du libellé à afficher (donc le message MQTT) à la configuration du bloc (le block_config encodé pour le bloc switch).

S’il apparaît que le libellé label correspond à la valeur indiquée dans le paramètre check (soit ’MARCHE’), alors le switch est marqué comme coché sur la page (avec l’attribut checked).

Le restant du contenu du bloc ne diffère pas vraiment des autres blocs présentés jusqu’à maintenant.

 

La publication de l’ordre ’MARCHE’ ou ’ARRET’ n’est pas implémentée directement au sein du bloc switch. Cette opération est déléguée à la fonction on_switch_change() (voir block.js) associée à l’événement onchange() du/des switch(s) par le template de base base.html.

Attacher l’événement onchange des switchs

Le bloc switch utilise un composant checkbox (<input type="checkbox">) pour manipuler l’état du composant. Il est possible de capturer le changement d’état du composant en greffant une fonction de rappel sur l’événement onchange.

Le but de l’événement onchange est d’envoyer une publication sur le broker MQTT lorsque la checkbox change d’état.

Le template de base est modifié de sorte à attacher la fonction JavaScript on_switch_change à l’événement onchange des switchs.

Le code suivant a été inséré dans /templates/base.html :

      <script type="text/javascript">  

     <!--  

        M.AutoInit();  

       $(document).ready(function(){  

         {#- Needed for html select ui -#}  

         $(’select’).formSelect();  

          

         {#- Toasting Flash Messages #}  

         ...  

 

         {#- Attach event to switch_block #}  

         $(’[id^="checkbox_switch_"]’).each( function(){  

           console.log( ’attach onchange to ’ + $(this)[0].id ); 

           $(this)[0].onchange = on_switch_change  

         } );  

 

         {#- Custom onDocumentReady Javascrit content -#}  

         {% block onDocumentReady %}{% endblock %}  

       }); 

       {% block javascript %}{% endblock %} 

     -->

La requête jQuery $(’[id^="checkbox_switch_"]’) permet de localiser tous les composants ayant un id commençant par "checkbox_switch_".

Ensuite, la méthode each() permet d’associer l’événement onchange pour chacun des éléments trouvés. L’événement onchange est attaché à la fonction on_switch_change() grâce à l’instruction $(this)[0].onchange = on_switch_change. Notez qu’un petit message dans la console JavaScript indique l’association.

 

La fonction JavaScript on_switch_change() est définie dans le fichier /app/static/js/block.js regroupant le code JavaScript utilisé par et pour les blocs.

Du code JavaScript complémentaire

Le template de base est également modifié pour inclure des ressources complémentaires :

3. Le switch et MQTT

Comme précisé en début de section (cf. Bloc Switch (marche/arrêt)), le bloc switch doit être capable d’envoyer des messages MARCHE/ARRET sur le topic maison/cave/chaufferie/cmd en vue de commander l’objet de la chaufferie.

Idéalement, le bloc switch devrait faire une souscription sur le topic maison/cave/chaufferie/etat pour détecter les changements d’état et les reporter immédiatement dans l’affichage du bloc.

images/07RI49.pngimages/07RI49.png
 

Publication de messages MQTT depuis JavaScript

La page générée pour afficher le tableau de bord doit pouvoir réaliser des publications et des souscriptions MQTT depuis le navigateur web. Cela suppose d’utiliser un client MQTT JavaScript.

a. Client MQTT JavaScript

La bibliothèque JavaScript du projet Eclipse Paho propose un client MQTT pouvant être utilisé dans un navigateur. Pour rappel, le projet Paho offre des implémentations open source de clients MQTT pour de nombreux langages de programmation.  

La bibliothèque JavaScript Paho est disponible sur le lien suivant : https://www.eclipse.org/paho/clients/js/

L’exemple suivant, inspiré du projet Paho, peut être utilisé pour tester le client MQTT JavaScript :

// Inclusion du script  

// <script type="text/javascript"  

//     src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js" >  

// </script>  

 

// Créer un client MQTT  

client = new Paho.MQTT.Client( "pythonic.local",  

                              9001,  

                              "Dashboard");  

 

// Définir les fonctions de rappel  

client.onConnectionLost = onConnectionLost;  

client.onMessageArrived = onMessageArrived;  

 

// Connecter le client  

client.connect({onSuccess:onConnect,  

               userName:"pusr103",  

               password:"21052017" });  

 

// Appelé quand le client se connecte  

function onConnect() {  

 console.log("onConnect");  

 

 // Réaliser une souscription  

 client.subscribe("maison/cave/chaufferie/etat");  

 

 // Envoi d’un message  

 message = new Paho.MQTT.Message("MARCHE");  

 message.destinationName = "maison/cave/chaufferie/cmd";  

 client.send(message);  

}  

 

// Appelé lors d’une perte de connexion  

function onConnectionLost(responseObject) {  

 if (responseObject.errorCode !== 0) {  

   console.log("onConnectionLost:"+responseObject.errorMessage);  

 }  

}  

 

// Appelé lors de la réception d’un message  

function onMessageArrived(message) {  

 console.log("onMessageArrived:"+message.payloadString);  

}

b. MQTT en JavaScript et WebSocket

La bibliothèque JavaScript Paho du client MQTT s’appuie sur les WebSockets.

Le WebSocket est une technologie web relativement récente autorisant la communication dans les deux sens. Cela permet au serveur d’envoyer des messages et des notifications vers le client (le script) fonctionnant dans le navigateur Internet.

Préalablement à la technologie WebSocket, seul le client pouvait initier l’envoi d’un message vers le serveur. Les applications web de type chat et messagerie devaient donc interroger régulièrement le serveur pour être averties de la disponibilité de messages.

Le graphique ci-dessous présente le cheminement d’une publication MQTT via le client MQTT JavaScript.

images/07RI50.pngimages/07RI50.png
 

Client MQTT JavaScript et WebSocket

Cela implique deux choses :

1.

Que le navigateur supporte la technologie WebSocket (ce qui est le cas des navigateurs récents, voir le lien http://caniuse.com/websockets pour plus de détails).

2.

Que le broker accepte des connexions WebSocket. Ce qui est prévu dans le cas du broker MQTT Mosquitto.

c. Activer le support WebSocket sur Mosquitto

Le support WebSocket s’active dans le fichier de configuration.

sudo nano /etc/mosquitto/mosquitto.conf

Dans lequel il faut ajouter les entrées suivantes juste au-dessus de la ligne pid_file /var/run/mosquitto.pid.

port 1883  

listener 9001  

protocol websockets

Puis, il est nécessaire de redémarrer le broker MQTT et push-to-db avec les commandes :

sudo systemctl restart mosquitto.service 

sudo systemctl restart push-to-db.service

 

Le service push-to-db doit être redémarré, car toutes ses souscriptions sont annulées lors du redémarrage du broker. Cela signifie que s’il n’est pas redémarré après le broker, push-to-db ne capturera plus aucun message !

d. Tester le client MQTT JavaScript

Pour tester le script d’exemple mentionné ci-dessus, il faut tout d’abord naviguer vers n’importe quelle page du projet Dashboard de façon à charger le fichier JavaScript https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.1/mqttws31.js dans le contexte courant.

Ceci fait, le script peut être testé depuis l’Ardoise JavaScript des outils de développements web du navigateur (cf. Navigateur FireFox) et ouvrir la console web permet de voir les différents messages de log JavaScript.

images/07RI51.pngimages/07RI51.png
 

Tester le client MQTT JavaScript depuis l’ardoise JavaScript

Pour une raison inconnue, la connexion MQTT via WebSocket retourne systématiquement une erreur, même lorsque l’authentification est désactivée sur le broker MQTT (cf. fichier mosquitto.conf). 

Les logs de Mosquitto disponibles dans /var/log/mosquitto/mosquitto.log mentionnent des erreurs sur le socket.

1536248457: New connection from 192.168.1.22 on port 1883.  

1536248457: Socket error on client <unknown>, disconnecting.  

1536248457: New connection from 192.168.1.22 on port 1883.  

1536248457: Socket error on client <unknown>, disconnecting.  

1536248519: New connection from 192.168.1.22 on port 1883.  

1536248519: Socket error on client <unknown>, disconnecting.  

1536248519: New connection from 192.168.1.22 on port 1883.

Selon les circonstances, il semblerait qu’une version trop récente de la bibliothèque libwebsockets 2.1.x provoque des problèmes de connexion WebSocket sur le broker MQTT Mosquitto 1.4 (version installée). Voir ce billet https://github.com/eclipse/mosquitto/issues/336.

Il est donc nécessaire d’attendre une mise à jour ou de procéder à une rétrogradation de libwebsockets à la version 2.0.x.

e. Mille milliards de mille sabords !

Derrière ce juron emprunté au capitaine Haddock (cf. bande dessinée « Tintin » de Hergé) se cache une très mauvaise surprise découverte lors de l’écriture de cette fin de chapitre.

Le code JavaScript proposé ci-dessus ne peut pas fonctionner en ce mois de septembre 2018 ! Une situation particulière qui n’existait pas il y a peu et qui sera certainement corrigée sous peu.

À vingt jours de la remise de l’ouvrage, il aura fallu accuser le choc, comprendre d’où venait le problème puis trouver rapidement une solution acceptable.

Mauvaise surprise et alternative

Le fait de ne pas pouvoir exploiter le client MQTT JavaScript constitue plus qu’une mauvaise surprise puisque cela va empêcher de contrôler l’objet chaufferie !

Après de nombreuses investigations et le temps pressant, c’est là où les bonnes vieilles recettes et technologies éprouvées peuvent s’avérer très utiles.

Si le client MQTT JavaScript ne peut pas envoyer de message directement au broker MQTT, il reste possible de le faire via une requête AJAX (Asynchronous JavaScript and XML) vers l’application Dashboard pour lui demander de réaliser cette publication vers le broker MQTT.

images/07RI52.pngimages/07RI52.png
 

Utilisation d’une requête AJAX à la place de WebSocket

Si cela permet de relayer facilement des publications vers le broker MQTT, cette solution de remplacement ne permettra pas d’être notifié des changements d’état.

En plaçant une route /MqttProxyPublish dans l’application Flask, le dashboard peut réceptionner une requête de publication via HTTP et utiliser le client MQTT Python pour republier le message vers le broker.

f. La route MqttProxyPublish

Le fichier views.py met en place une nouvelle route /MqttProxyPublish capable de réceptionner une requête AJAX comportant les trois éléments suivants dans une structure JSON :

01: @app.route( ’/MqttProxyPublish’, methods=[’POST’] )  

02: def mqtt_publish_proxy():  

03:    data = request.data  

04:    app.logger.debug( u’MqttProxyPublish for %s’% data )  

05:    try:  

06:       dataDict = json.loads(data)  

07:    except Exception as err:  

08:       return make_response(  

09:                  (u’Format JSON invalide. %s’%err, 400) )  

10:          

11:    # Doit avoir les éléménts suivants :  

12:    # data = {"source":"mqtt_pythonic", 

13:    #         "topic":"maison/cave/chaufferie/cmd", 

14:    #         "msg":"ARRET"}  

15:    try:  

16:       assert ’source’ in dataDict, "JSON: ’source’ manquante."  

17:       assert ’topic’  in dataDict, "JSON: ’topic’ manquante."  

18:       assert ’msg’    in dataDict, "JSON: ’msg’ manquante."  

19:    except Exception as err:  

20:       return make_response(  

21:                (u’Format JSON invalide. %s’%err,400) )  

22:  

23:    _source = dataDict[’source’]  

24:    _topic  = dataDict[’topic’]  

25:    _msg    = dataDict[’msg’]  

26:    try:  

27:       mqtt_info = get_mqtt_sources(as_dict=True)[_source]  

28:    except:  

29:       return make_response( (u’Source invalide!’, 400) )  

30:  

31:    try:  

32:       import paho.mqtt.client as mqtt_client  

33:       client = mqtt_client.Client(  

34:                   client_id="dashboard_MqttProxyPublish" )  

35:       if mqtt_info[’username’]:     

36:          client.username_pw_set(  

37:             username=mqtt_info[’username’], 

38:             password=mqtt_info[’passwd’])  

39:        

40:       client.connect( host=mqtt_info[’server’],  

41:                       port=mqtt_info[’port’] )  

42:       client.publish( _topic, _msg ) # QoS 0  

43:    except Exception as err:  

44:       app.logger.error( ’MqttProxyPublish impossible

de relayer vers le Broker pour %s’ % data  ) 

45:       app.logger.error( err )  

46:       make_response( ( u’Erreur Broker! %s’%err , 400) )  

47:  

48: return make_response( (u’%s envoyé!’%_msg , 200 ) )

g. Événement on_switch_change

La fonction JavaScript on_switch_change() est définie dans le fichier /app/static/js/block.js.

La fonction on_switch_change() est rattachée à tous les switchs (événement onchange) durant le chargement de la page.

Cette fonction est appelée à chaque fois qu’un switch (checkbox) change d’état.

Voici le détail de la fonction JavaScript on_switch_change.

01: function on_switch_change( event ){  

02:    var checkbox = event.target  

03:  

04:    console.debug( ’on_switch_change sur id ’+checkbox.id+ 

05:       ’ pour ’+  

06:       (checkbox.checked ? " CHECKED " : " unchecked ") );  

07:   // Extraction du block ID (ex : "checkbox_switch_14")  

08:   var _arr = checkbox.id.split(’_’);  

09:   var block_id = _arr[ _arr.length-1 ];  

10:   // Retrouver le block_config correspondant 

11:   var block_config = block_configs[ block_id ];  

12:   var err = check_block_config ( ’switch’,  

13:               block_config );  

14:   if( err ){  

15:      M.toast( { 

16:         html:’Erreur configuration block: ’+err, 

17:         classes:’red’ } );  

18:      return  

19:   }  

20: 

21:   if( checkbox.checked ){  

22:      var _source = block_config.action.checked.source;  

23:      var _topic  = block_config.action.checked.topic;  

24:      var _msg    = block_config.action.checked.message;  

25:   }  

26:   else {  

27:      var _source = block_config.action.unchecked.source;  

28:      var _topic  = block_config.action.unchecked.topic;  

29:      var _msg    = block_config.action.unchecked.message;  

30:   }  

31:     

32:   console.log( JSON.stringify(  

33:      {source:_source, 

34:      topic:_topic,_msg:_msg} ) ); 

35:  

36:   var jqxhr = $.ajax({ 

37:         url: ’/MqttProxyPublish’,  

38:         type: ’POST’,  

39:         data: JSON.stringify(  

40:            {source:_source, 

41:            topic:_topic,msg:_msg} ),  

42:         contentType: ’application/json; charset=utf-8’  

43:      }).done( 

44: 

45:         function(response, status, xhr) {  

46:            M.toast( { 

47:               html: response,  

48:               classes: ’green’ } );  

49:      }).fail( 

50: 

51:         function(response, status, xhr) {  

52:            M.toast( { 

53:               html: response.responseText,  

54:               classes: ’red’ } );  

55:            M.toast( { 

56:               html:’Erreur ajax!’,  

57:               classes:’red’} );  

58:      }) .always( 

59:          

60:         function() {  

61:            console.debug( ’’ );  

62:      });  

63:      

64: }

4. Tester le bloc switch

Le plus simple pour vérifier le fonctionnement de l’ensemble est d’utiliser l’utilitaire mosquitto_sub pour vérifier les messages circulants sur les sous-topics de maison/cave/chaufferie.

mosquitto_sub -h pythonic.local -t "maison/cave/chaufferie/#" -v -u "pusr103"

-P "21052017"

ce qui produit le résultat suivant lorsque le switch est activé et déactivé à tour de rôle.

$ mosquitto_sub -h pythonic.local -t "maison/cave/chaufferie/#" -v -u 

"pusr103" -P "21052017" 

maison/cave/chaufferie/temp-eau 23.50  

maison/cave/chaufferie/temp-eau 23.69  

maison/cave/chaufferie/temp-eau 23.75  

maison/cave/chaufferie/temp-eau 23.94 

maison/cave/chaufferie/cmd ARRET 

maison/cave/chaufferie/temp-eau 24.06 

maison/cave/chaufferie/etat ARRET 

maison/cave/chaufferie/temp-eau 24.06  

maison/cave/chaufferie/temp-eau 24.06 

maison/cave/chaufferie/cmd MARCHE  

maison/cave/chaufferie/etat MARCHE 

maison/cave/chaufferie/temp-eau 24.06  

maison/cave/chaufferie/temp-eau 24.06 

maison/cave/chaufferie/cmd ARRET  

maison/cave/chaufferie/etat ARRET 

maison/cave/chaufferie/temp-eau 24.06  

maison/cave/chaufferie/temp-eau 24.06

L’activation du switch dans l’interface :

images/07RI53.pngimages/07RI53.png
 

Activation du bloc Switch

Active le relais sur l’objet chaufferie :

images/07RI54.pngimages/07RI54.png
 

Activation du relais (Power Switch Tail) sur l’objet chaufferie

La désactivation du switch dans l’interface :

images/07RI55.pngimages/07RI55.png
 

Désactivation du switch

Désactive également le relais de l’objet :

images/07RI56.pngimages/07RI56.png
 

Désactivation du relais sur l’objet

 

 

La mise à jour des informations dans le bloc switch doit attendre la capture de l’information par push-to-db et le rafraîchissement de la page.

images/07RI57.pngimages/07RI57.png
 

Mise à jour de l’information du bloc après recapture de l’état par push-to-db

Améliorations

De nombreuses améliorations peuvent rapidement prendre place dans le projet Dashboard grâce à l’apparition du block_config (dont la valeur peut être modifiée facilement dans l’éditeur de bloc).

Installation rapide

Le point ci-dessous reprend des informations provenant des différents chapitres et permettant de réinstaller et redémarrer rapidement le projet. Cette annexe est également reprise sur le dépôt GitHub du projet où elle sera éventuellement amendée.

1. Prérequis

Avant de débuter l’installation, il faudra :

1.

Installer le système d’exploitation sur le Raspberry Pi et le configurer comme indiqué dans le chapitre Présentation.

2.

Localiser les sources du projet qui seront réinstallées. Ces sources proviennent soit de l’archive disponible sur le site des Éditions ENI, soit des fichiers téléchargés depuis le dépôt GitHub du projet.

2. Début de l’installation

Installer Mosquitto

sudo apt-get install mosquitto mosquitto-clients

 Créez un fichier passwd et ajoutez l’utilisateur Mosquitto pusr103.

sudo mosquitto_passwd -c /etc/mosquitto/passwd pusr103

 

Le mot de passe utilisé durant la configuration du projet est 21052017.

Modifier la configuration Mosquitto

sudo nano /etc/mosquitto/mosquitto.conf

 Et ajoutez-y les lignes :

allow_anonymous false  

password_file /etc/mosquitto/passwd

Redémarrer Mosquitto

sudo systemctl stop mosquitto.service

sudo systemctl start mosquitto.service

3. Récupération des sources

Ces sources peuvent être récupérées soit depuis la page Informations générales (copie du projet opérée à l’édition de l’ouvrage), soit depuis le dépôt GitHub du projet, où cette copie est également disponible.

Depuis la page Informations générales

Télécharger l’archive depuis le lien. Assurez-vous que l’archive des sources porte le nom LFPYRASPFL.zip.

Une fois l’archive téléchargée, les sources peuvent être extraites à l’aide de la commande :

unzip -e LFPYRASPFL.zip

Une fois le contenu de l’archive extrait, le répertoire utilisateur doit contenir un répertoire nommé la-maison-pythonic avec les sources du projet.

La copie à l’édition du livre (sur GitHub)

 Saisir la commande suivante pour récupérer l’archive :

cd  ~ 

wget https://github.com/mchobby/la-maison-pythonic/raw/master/res/

la-maison-pythonic-(master-livre).zip

Une fois l’archive téléchargée, les sources peuvent être extraites à l’aide de la commande :

unzip -e la-maison-pythonic-(master-livre).zip

Une fois le contenu de l’archive extrait, le répertoire utilisateur doit contenir un répertoirela-maison-pythonic avec les sources du projet.

Depuis le dépôt GitHub du projet

Le projet la-maison-pythonic est également disponible sur le dépôt GitHub, ce dernier ayant continué ses évolutions depuis la sortie de l’ouvrage.

Le projet peut être dupliqué dans le répertoire utilisateur à l’aide des commandes :

cd  ~ 

git clone https://github.com/mchobby/la-maison-pythonic.git

Une fois l’opération terminée, le répertoire utilisateur doit contenir un répertoire la-maison-pythonic avec les sources du projet.

4. Poursuivre l’installation

Installer push-to-db

cd ~/la-maison-pythonic/python/push-to-db/ 

./setup.sh

 

Il est possible que le script soit dans l’impossibilité de créer la base de données (voir message d’erreur en fin de script). Cela est dû au fait que le script attache l’utilisateur pi à un nouveau groupe, mais que cette modification n’est pas instantanément effective. Par conséquent, il faut déconnecter l’utilisateur pi, puis le reconnecter avant de relancer le script une seconde fois.

Tester push-to-db

 Testez le script avec les commandes suivantes :

cd ~/la-maison-pythonic/python/push-to-db/ 

python push-to-db.py

Le script doit afficher différents messages au démarrage et à la réception de messages MQTT. Une fois le bon fonctionnement confirmé, pressez [Ctrl] C pour interrompre le script Python.

Démarrer push-to-db avec systemd

 Installez le fichier de configuration.

cp ~/la-maison-pythonic/python/push-to-db/push-to-db.service.sample

/lib/systemd/system/push-to-db.service 

sudo chmod 644 /lib/systemd/system/push-to-db.service

 Rechargez la configuration de systemd.

sudo systemctl daemon-reload

 Activez le service.

sudo systemctl enable push-to-db.service

sudo systemctl start push-to-db.service

 Vérifiez que le service est bien démarré correctement.

sudo systemctl status push-to-db.service

Installer le dashboard

cd ~/la-maison-pythonic/python/dashboard/install/ 

./setup.sh

Copier les bases de données de démonstration

 

Ce point est optionnel. Il peut être remplacé par la réinstallation des backups à disposition.

 Arrêtez le service push-to-db.

sudo systemctl stop push-to-db.service

 Copiez les bases de données.

cd ~/la-maison-pythonic/python/dashboard/install/

cd demodb

cp *.db /var/local/sqlite

 Redémarrez le service push-to-db.

sudo systemctl start push-to-db.service

Démarrer dashboard avec systemd

 Installez le fichier de configuration.

cp ~/la-maison-pythonic/python/dashboard/install/dashboard.service.sample

/lib/systemd/system/dashboard.service

sudo chmod 644 /lib/systemd/system/dashboard.service

 Rechargez la configuration de systemd.

sudo systemctl daemon-reload

 Activez le service.

sudo systemctl enable dashboard.service

sudo systemctl start dashboard.service

 Vérifiez que le service a démarré correctement.

sudo systemctl status dashboard.service

Tester le dashboard

 Pour tester le dashboard, il faut démarrer un navigateur internet, puis saisir l’URL correspondant au Raspberry Pi.

Selon la configuration décrite au chapitre Présentation, les URL possibles sont :